diff --git a/.gitignore b/.gitignore index a9b9c9a..aaa53b1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -# Logs logs *.log npm-debug.log* @@ -7,18 +6,15 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -# Node/Frontend Dependencies and Build Output node_modules dist dist-ssr *.local -# Environment Variables env .env* !.env.example -# Editor directories and files .vscode/* !.vscode/extensions.json .idea @@ -29,7 +25,6 @@ env *.sln *.sw? -# Python Caches and Bytecode __pycache__/ *.pyc *.pyo @@ -38,5 +33,4 @@ __pycache__/ .mypy_cache/ .ruff_cache/ -# General Caches .cache/ diff --git a/CODEOWNERS b/CODEOWNERS deleted file mode 100644 index 5653b7d..0000000 --- a/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -@Ahmonemb diff --git a/README.md b/README.md index 9e33f6b..c085f99 100644 --- a/README.md +++ b/README.md @@ -1,83 +1 @@ -# 🎓 College Transfer AI - -**College Transfer AI** is an intelligent planning tool that helps California Community College students generate a personalized PDF report showing transferable courses to selected University of California (UC) campuses for a chosen major and transfer year. If a required course is not offered at the primary community college, the system searches other CCCs for articulated equivalents. It even includes an AI counselor that provides tailored academic advice based on the report. - ---- - -## 🚀 Features - -- ✅ Input your: - - Target UC campuses (e.g., UC Berkeley, UC San Diego) - - Major (e.g., Computer Science) - - Transfer year (e.g., Fall 2026) - - Primary CCC (e.g., Diablo Valley College) - -- 📄 Outputs a PDF for each selected UC campus: - - Lists all transferable courses needed for the selected major. - - Maps CCC equivalents with course name, unit count, and originating college. - - Indicates if a course is not offered at your CCC and shows an alternative from another CCC (if available). - - Clearly notes any courses that must be taken at the UC post-transfer. - -- 💬 Includes an AI guidance counselor: - - Reads the PDF and user input. - - Recommends a personalized education plan. - - Provides helpful advice and strategies for successful transfer. - ---- - -## 📥 Getting Started - -```bash -# Clone the repository -git clone https://github.com/your-username/college-transfer-ai.git -cd college-transfer-ai - -# (Optional) Create a virtual environment -python -m venv venv -source venv/bin/activate # or venv\Scripts\activate on Windows - -# Install dependencies -pip install -r requirements.txt - -# Run the script -python -m college_transfer_ai.app -``` -## 📌 Example - -For a student transferring **Computer Science** from **Los Medanos College** and **Diablo Valley College** to **UC Berkeley**, the system generates a PDF showing: - -- Required UC courses (e.g., CS 61A, Math 1B) -- Equivalent CCC courses (e.g., COMSC 132 at LMC) -- Notes if a course must be taken after transfer or is unavailable at any CCC -- Multiple CCCs where required courses may be completed - -Each PDF starts with the name of the UC campus, followed by the list of CCCs where articulated classes can be taken. If no CCC offers a transferable course, the PDF clearly states that. - ---- - -## 🤖 Coming Soon - -- Web interface for input/output -- Full integration with AI academic advisor -- Export education plan to calendar/schedule - ---- - -## 📄 License - -This project is licensed under the MIT License. - ---- - -## 🤝 Contributing - -Contributions are welcome! Open an issue to discuss your idea or submit a pull request. - ---- - -## 📬 Contact - -Made by [Ahmon Embaye] - -For questions or support, reach out at [ahmonembaye@example.com] or via GitHub Issues. - +Still in development. Testing phase coming soon. diff --git a/backend/college_transfer_ai/__init__.py b/backend/college_transfer_ai/__init__.py new file mode 100644 index 0000000..e26e98e --- /dev/null +++ b/backend/college_transfer_ai/__init__.py @@ -0,0 +1,63 @@ +import os +import traceback +from flask import Flask +from flask_cors import CORS + +from college_transfer_ai.config import load_configuration +from college_transfer_ai.database import init_db, close_db +from college_transfer_ai.routes.stripe_routes import stripe_bp +from college_transfer_ai.routes.agreement_pdf_routes import agreement_pdf_bp +from college_transfer_ai.routes.chat_routes import init_chat_routes +from college_transfer_ai.routes.course_map_routes import course_map_bp +from college_transfer_ai.routes.user_routes import user_bp +from college_transfer_ai.routes.api_info_routes import api_info_bp +from college_transfer_ai.routes.igetc_routes import igetc_bp + +def create_app(): + app = Flask(__name__) + + print("--- Creating Flask App ---") + + try: + config = load_configuration() + app.config['APP_CONFIG'] = config + except Exception as config_err: + print(f"!!! CRITICAL: Failed to load configuration: {config_err}") + traceback.print_exc() + exit(1) + + cors_origins = config.get("FRONTEND_URL", "*") + CORS(app, resources={r"/*": {"origins": cors_origins}}) + print(f"--- CORS Initialized (Origins: {cors_origins}) ---") + + try: + init_db(app, config.get('MONGO_URI')) + except (ConnectionError, ValueError, Exception) as db_err: + print(f"!!! CRITICAL: Database initialization failed: {db_err}") + if not isinstance(db_err, (ConnectionError, ValueError)): + traceback.print_exc() + exit(1) + + try: + init_chat_routes(app) + except Exception as gemini_err: + print(f"!!! WARNING: Failed to initialize Gemini/Chat: {gemini_err}") + + api_prefix = '/api' + app.register_blueprint(stripe_bp, url_prefix=api_prefix) + app.register_blueprint(agreement_pdf_bp, url_prefix=api_prefix) + app.register_blueprint(course_map_bp, url_prefix=api_prefix) + app.register_blueprint(user_bp, url_prefix=api_prefix) + app.register_blueprint(api_info_bp, url_prefix=api_prefix) + app.register_blueprint(igetc_bp, url_prefix=api_prefix) + print(f"--- Blueprints Registered (Prefix: {api_prefix}) ---") + + @app.route('/') + def index(): + return "College Transfer AI Backend is running." + + app.teardown_appcontext(close_db) + print("--- Database teardown function registered ---") + + print("--- Flask App Creation Complete ---") + return app \ No newline at end of file diff --git a/backend/college_transfer_ai/app.py b/backend/college_transfer_ai/app.py deleted file mode 100644 index 47c26fd..0000000 --- a/backend/college_transfer_ai/app.py +++ /dev/null @@ -1,509 +0,0 @@ -import os -import base64 -import traceback -import uuid # Import uuid for map IDs -import datetime # Import datetime -from flask import Flask, jsonify, request, Response -from flask_cors import CORS -from .college_transfer_API import CollegeTransferAPI # Use a leading dot -import gridfs -from pymongo import MongoClient -import fitz -from openai import OpenAI -from dotenv import load_dotenv - -# --- Google Auth Imports --- -from google.oauth2 import id_token -from google.auth.transport import requests as google_requests -# --- End Google Auth Imports --- - -# --- Google AI Imports --- -import google.generativeai as genai -from google.generativeai.types import HarmCategory, HarmBlockThreshold -# --- End Google AI Imports --- - -print("--- Flask app.py loading ---") -load_dotenv() - -# --- Config Vars --- -openai_api_key = os.getenv("OPENAI_API_KEY") -MONGO_URI = os.getenv("MONGO_URI") -GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") -GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY") -# --- End Config Vars --- - -# --- Add this print statement --- -print(f"--- Attempting to configure Google AI with Key: '{GOOGLE_API_KEY}' ---") -# --- End print statement --- - -# Google AI Client Setup -if not GOOGLE_API_KEY: - print("Warning; GOOGLE_API_KEY not set. Google AI chat will fail.") -else: - try: - genai.configure(api_key=GOOGLE_API_KEY) - print("Google AI client configured successfully.") - except Exception as e: - print(f"CRITICAL: Failed to configure Google AI client: {e}") -# --- End Google AI Client Setup --- - -# --- Client Setups --- -if not openai_api_key: print("Warning: OPENAI_API_KEY not set.") -openai_client = OpenAI(api_key=openai_api_key) if openai_api_key else None - -if not MONGO_URI: print("CRITICAL: MONGO_URI not set.") -try: - client = MongoClient(MONGO_URI) - client.admin.command('ping') - print("MongoDB connection successful.") - db = client["CollegeTransferAICluster"] - fs = gridfs.GridFS(db) - course_maps_collection = db["course_maps"] # Collection remains the same name - # Ensure index on google_user_id and map_id for efficient lookups - course_maps_collection.create_index([("google_user_id", 1)]) - course_maps_collection.create_index([("google_user_id", 1), ("map_id", 1)], unique=True) - -except Exception as e: - print(f"CRITICAL: Failed to connect to MongoDB or create index: {e}") - # exit(1) - -if not GOOGLE_CLIENT_ID: - print("Warning: GOOGLE_CLIENT_ID not set. Google Sign-In endpoints will fail.") -# --- End Client Setups --- - - -BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -app = Flask(__name__, template_folder=os.path.join(BASE_DIR, 'templates'), static_folder=os.path.join(BASE_DIR, 'static')) -CORS(app, supports_credentials=True, origins=["http://localhost:5173"]) -app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 - -api = CollegeTransferAPI() - -# --- Helper: Verify Google Token (remains the same) --- -def verify_google_token(token): - """Verifies Google ID token and returns user info.""" - if not GOOGLE_CLIENT_ID: - raise ValueError("Google Client ID not configured on backend.") - try: - idinfo = id_token.verify_oauth2_token( - token, google_requests.Request(), GOOGLE_CLIENT_ID - ) - google_user_id = idinfo['sub'] - print(f"Token verified for user: {google_user_id}") - return idinfo - except ValueError as e: - print(f"Token verification failed: {e}") - raise ValueError(f"Invalid Google token: {e}") - except Exception as e: - print(f"An unexpected error occurred during token verification: {e}") - raise ValueError(f"Token verification error: {e}") -# --- End Helper --- - - -# --- Existing Endpoints (Home, Institutions, PDF/Image handling, Chat etc. remain the same) --- -@app.route('/') -def home(): return "College Transfer AI API is running." -# ... /institutions, /receiving-institutions, /academic-years, /majors ... -# ... /articulation-agreement, /pdf-images, /image ... -# ... /chat ... -# (Keep all existing endpoints as they were) -# Endpoint to get all institutions -@app.route('/institutions', methods=['GET']) -def get_institutions(): - try: - institutions = api.get_sending_institutions() - return jsonify(institutions) - except Exception as e: - print(f"Error in /institutions: {e}") - return jsonify({"error": str(e)}), 500 - -# Endpoint to get receiving institutions -@app.route('/receiving-institutions', methods=['GET']) -def get_receiving_institutions(): - sending_institution_id = request.args.get('sendingInstitutionId') - if not sending_institution_id: - return jsonify({"error": "Missing sendingInstitutionId parameter"}), 400 - try: - non_ccs = api.get_receiving_institutions(sending_institution_id) - return jsonify(non_ccs) - except Exception as e: - print(f"Error in /receiving-institutions: {e}") - return jsonify({"error": str(e)}), 500 - -# Endpoint to get academic years -@app.route('/academic-years', methods=['GET']) -def get_academic_years(): - try: - academic_years = api.get_academic_years() - return jsonify(academic_years) - except Exception as e: - print(f"Error in /academic-years: {e}") - return jsonify({"error": str(e)}), 500 - -# Endpoint to get majors -@app.route('/majors', methods=['GET']) -def get_all_majors(): - sending_institution_id = request.args.get('sendingInstitutionId') - receiving_institution_id = request.args.get('receivingInstitutionId') - academic_year_id = request.args.get('academicYearId') - category_code = request.args.get('categoryCode') - if not all([sending_institution_id, receiving_institution_id, academic_year_id, category_code]): - return jsonify({"error": "Missing required parameters (sendingInstitutionId, receivingInstitutionId, academicYearId, categoryCode)"}), 400 - try: - majors = api.get_all_majors(sending_institution_id, receiving_institution_id, academic_year_id, category_code) - return jsonify(majors) - except Exception as e: - print(f"Error in /majors: {e}") - return jsonify({"error": str(e)}), 500 - -# Endpoint to get articulation agreement PDF filename -@app.route('/articulation-agreement', methods=['GET']) -def get_articulation_agreement(): - key = request.args.get("key") - if not key: - return jsonify({"error": "Missing key parameter"}), 400 - try: - keyArray = key.split("/") - if len(keyArray) < 4: - return jsonify({"error": "Invalid key format"}), 400 - sending_institution_id = int(keyArray[1]) - receiving_institution_id = int(keyArray[3]) - academic_year_id = int(keyArray[0]) - pdf_filename = api.get_articulation_agreement(academic_year_id, sending_institution_id, receiving_institution_id, key) - return jsonify({"pdf_filename": pdf_filename}) - except ValueError: - return jsonify({"error": "Invalid numeric value in key"}), 400 - except Exception as e: - print(f"Error in /articulation-agreement: {e}") - return jsonify({"error": str(e)}), 500 - -# Endpoint to get image filenames for a PDF (extracts if needed) -@app.route('/pdf-images/') -def get_pdf_images(filename): - try: - pdf_file = fs.find_one({"filename": filename}) - if not pdf_file: - return jsonify({"error": "PDF not found"}), 404 - - pdf_bytes = pdf_file.read() - doc = fitz.open("pdf", pdf_bytes) - image_filenames = [] - - # Check cache - first_image_name = f"{filename}_page_0.png" - if fs.exists({"filename": first_image_name}): - for page_num in range(len(doc)): - img_filename = f"{filename}_page_{page_num}.png" - if fs.exists({"filename": img_filename}): - image_filenames.append(img_filename) - else: - print(f"Cache incomplete, image {img_filename} missing. Regenerating.") - image_filenames = [] - break - if image_filenames: - print(f"All images for {filename} found in cache.") - doc.close() - return jsonify({"image_filenames": image_filenames}) - - # If not fully cached, extract/save - print(f"Generating images for {filename}...") - image_filenames = [] - for page_num in range(len(doc)): - page = doc.load_page(page_num) - pix = page.get_pixmap(dpi=150) - img_bytes = pix.tobytes("png") - img_filename = f"{filename}_page_{page_num}.png" - existing_file = fs.find_one({"filename": img_filename}) - if existing_file: fs.delete(existing_file._id) - fs.put(img_bytes, filename=img_filename, contentType='image/png') - image_filenames.append(img_filename) - print(f"Saved image {img_filename}") - - doc.close() - return jsonify({"image_filenames": image_filenames}) - - except Exception as e: - print(f"Error extracting images for {filename}: {e}") - traceback.print_exc() # Print full traceback for debugging - return jsonify({"error": f"Failed to extract images: {str(e)}"}), 500 - -# Endpoint to serve a single image -@app.route('/image/') -def serve_image(image_filename): - try: - grid_out = fs.find_one({"filename": image_filename}) - if not grid_out: return "Image not found", 404 - response = Response(grid_out.read(), mimetype=getattr(grid_out, 'contentType', 'image/png')) - return response - except Exception as e: - print(f"Error serving image {image_filename}: {e}") - return jsonify({"error": f"Failed to serve image: {str(e)}"}), 500 - -# Chat Endpoint (remains the same, does not use Google Auth) -@app.route('/chat', methods=['POST']) -def chat_with_agreement(): - if not GOOGLE_API_KEY: # Check if Google AI is configured - return jsonify({"error": "Google AI client not configured."}), 500 - - try: - data = request.get_json() - if not data: return jsonify({"error": "Invalid JSON payload"}), 400 - - new_user_message_text = data.get('new_message') - # Google's ChatSession manages history internally, but we need to format it initially - openai_history = data.get('history', []) - image_filenames = data.get('image_filenames') # Only sent on first turn - - if not new_user_message_text: return jsonify({"error": "Missing 'new_message' text"}), 400 - - # Inside the /chat endpoint - - # --- Prepare Input for Gemini --- - prompt_parts = [new_user_message_text] # Start with the text part - - if image_filenames: - print(f"Processing {len(image_filenames)} images for Gemini.") - image_count = 0 - for filename in image_filenames: - try: - grid_out = fs.find_one({"filename": filename}) - if not grid_out: - print(f"Image {filename} not found in GridFS. Skipping.") - continue - - image_bytes = grid_out.read() - # --- Determine MIME type (ensure it's correct) --- - # Use getattr for safety, default to image/png if not found - mime_type = getattr(grid_out, 'contentType', None) - if not mime_type: - # Basic check based on filename extension if contentType is missing - if filename.lower().endswith(".jpg") or filename.lower().endswith(".jpeg"): - mime_type = "image/jpeg" - elif filename.lower().endswith(".png"): - mime_type = "image/png" - # Add more types if needed (webp, heic, heif) - else: - mime_type = "image/png" # Default fallback - print(f"Warning: contentType missing for {filename}, inferred as {mime_type}") - # --- End MIME type determination --- - - - # --- Correct way to add image part --- - # Append a dictionary, not genai.types.Part - prompt_parts.append({ - "mime_type": mime_type, - "data": image_bytes # Send raw bytes directly - }) - # --- End Correction --- - - image_count += 1 - except Exception as img_err: - print(f"Error reading/processing image {filename} for Gemini: {img_err}. Skipping.") - print(f"Added {image_count} images to Gemini prompt.") - else: - print("No image filenames provided for Gemini.") - - # --- Convert OpenAI history to Gemini format (remains the same) --- - gemini_history = [] - # ... (history conversion code) ... - - # --- Initialize Gemini Model and Chat (remains the same) --- - model = genai.GenerativeModel('gemini-1.5-flash-latest') - chat = model.start_chat(history=gemini_history) - - print(f"Sending request to Gemini with {len(prompt_parts)} parts...") - - # --- Send message to Gemini (remains the same) --- - safety_settings = { # Optional safety settings - # ... (safety settings) ... - } - response = chat.send_message(prompt_parts, safety_settings=safety_settings) - - assistant_reply = response.text - print("Received reply from Gemini.") - - return jsonify({"reply": assistant_reply}) - - # ... (exception handling remains the same) ... - - except Exception as e: - print(f"Error in /chat endpoint (Gemini): {e}") - traceback.print_exc() - # Try to provide a more specific error if possible - error_message = f"An unexpected error occurred with the AI chat: {str(e)}" - # Check for specific Google API errors if needed - # if isinstance(e, google.api_core.exceptions.GoogleAPIError): - # error_message = f"Google API Error: {e}" - return jsonify({"error": error_message}), 500 - -# --- End Chat Endpoint --- - - -# --- Course Map Endpoints --- - -# GET /api/course-maps - List all maps for the user -@app.route('/course-maps', methods=['GET']) -def list_course_maps(): - try: - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Authorization token missing or invalid"}), 401 - token = auth_header.split(' ')[1] - user_info = verify_google_token(token) - google_user_id = user_info['sub'] - - # Find maps for the user, projecting only necessary fields - maps_cursor = course_maps_collection.find( - {'google_user_id': google_user_id}, - {'_id': 0, 'map_id': 1, 'map_name': 1, 'last_updated': 1} # Project fields - ).sort('last_updated', -1) # Sort by most recently updated - - map_list = list(maps_cursor) - print(f"Found {len(map_list)} maps for user {google_user_id}") - return jsonify(map_list), 200 - - except ValueError as auth_err: - return jsonify({"error": str(auth_err)}), 401 - except Exception as e: - print(f"Error listing course maps: {e}") - traceback.print_exc() - return jsonify({"error": f"Failed to list course maps: {str(e)}"}), 500 - -# GET /api/course-map/ - Load a specific map -@app.route('/course-map/', methods=['GET']) -def load_specific_course_map(map_id): - try: - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Authorization token missing or invalid"}), 401 - token = auth_header.split(' ')[1] - user_info = verify_google_token(token) - google_user_id = user_info['sub'] - - # Find the specific map for the user - map_data = course_maps_collection.find_one( - {'google_user_id': google_user_id, 'map_id': map_id}, - {'_id': 0} # Exclude MongoDB ID - ) - - if map_data: - print(f"Loaded map {map_id} for user {google_user_id}") - return jsonify(map_data), 200 - else: - print(f"Map {map_id} not found for user {google_user_id}") - return jsonify({"error": "Map not found"}), 404 - - except ValueError as auth_err: - return jsonify({"error": str(auth_err)}), 401 - except Exception as e: - print(f"Error loading course map {map_id}: {e}") - traceback.print_exc() - return jsonify({"error": f"Failed to load course map: {str(e)}"}), 500 - -# POST /api/course-map - Save/Update a map -@app.route('/course-map', methods=['POST']) -def save_or_update_course_map(): - try: - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Authorization token missing or invalid"}), 401 - token = auth_header.split(' ')[1] - user_info = verify_google_token(token) - google_user_id = user_info['sub'] - - data = request.get_json() - if not data or 'nodes' not in data or 'edges' not in data: - return jsonify({"error": "Invalid payload: 'nodes' and 'edges' required"}), 400 - - nodes = data['nodes'] - edges = data['edges'] - map_id = data.get('map_id') # Get map_id if provided (for updates) - map_name = data.get('map_name', 'Untitled Map') # Get name or use default - - current_time = datetime.datetime.utcnow() - - if map_id: # Update existing map - print(f"Updating map {map_id} for user {google_user_id}") - result = course_maps_collection.update_one( - {'google_user_id': google_user_id, 'map_id': map_id}, - {'$set': { - 'map_name': map_name, - 'nodes': nodes, - 'edges': edges, - 'last_updated': current_time - }} - ) - if result.matched_count == 0: - return jsonify({"error": "Map not found or permission denied"}), 404 - saved_map_id = map_id - message = "Course map updated successfully" - else: # Create new map - new_map_id = str(uuid.uuid4()) # Generate a new unique ID - print(f"Creating new map {new_map_id} for user {google_user_id}") - map_document = { - 'google_user_id': google_user_id, - 'map_id': new_map_id, - 'map_name': map_name, - 'nodes': nodes, - 'edges': edges, - 'created_at': current_time, # Add created timestamp - 'last_updated': current_time - } - result = course_maps_collection.insert_one(map_document) - saved_map_id = new_map_id - message = "Course map created successfully" - - return jsonify({"message": message, "map_id": saved_map_id}), 200 # Return the map_id - - except ValueError as auth_err: - return jsonify({"error": str(auth_err)}), 401 - except Exception as e: - print(f"Error saving/updating course map: {e}") - traceback.print_exc() - return jsonify({"error": f"Failed to save course map: {str(e)}"}), 500 - -# DELETE /api/course-map/ - Delete a specific map -@app.route('/course-map/', methods=['DELETE']) -def delete_specific_course_map(map_id): - try: - auth_header = request.headers.get('Authorization') - if not auth_header or not auth_header.startswith('Bearer '): - return jsonify({"error": "Authorization token missing or invalid"}), 401 - token = auth_header.split(' ')[1] - user_info = verify_google_token(token) - google_user_id = user_info['sub'] - - # --- Add Logging --- - print(f"[Delete Request] Received Map ID: {map_id}, User ID from Token: {google_user_id}") - # --- End Logging --- - - # Delete the specific map for the user - result = course_maps_collection.delete_one( - {'google_user_id': google_user_id, 'map_id': map_id} - ) - - # --- Add Logging --- - print(f"[Delete Result] Deleted: {result.deleted_count}") - # --- End Logging --- - - if result.deleted_count > 0: - print(f"Deleted map {map_id} for user {google_user_id}") - # ... remove cache key if needed ... - return jsonify({"message": "Map deleted successfully"}), 200 - else: - print(f"Map {map_id} not found for deletion for user {google_user_id}") - return jsonify({"error": "Map not found or permission denied"}), 404 - - except ValueError as auth_err: - return jsonify({"error": str(auth_err)}), 401 - except Exception as e: - print(f"Error deleting course map {map_id}: {e}") - traceback.print_exc() - return jsonify({"error": f"Failed to delete course map: {str(e)}"}), 500 -# --- End Course Map Endpoints --- - - -if __name__ == '__main__': - is_debug = os.getenv("FLASK_DEBUG", "False").lower() in ("true", "1", "t") - print(f"Running Flask app with debug={is_debug}") - app.run(host='0.0.0.0', port=5000, debug=is_debug) \ No newline at end of file diff --git a/backend/college_transfer_ai/assist_api_client.py b/backend/college_transfer_ai/assist_api_client.py new file mode 100644 index 0000000..36f2b4a --- /dev/null +++ b/backend/college_transfer_ai/assist_api_client.py @@ -0,0 +1,78 @@ +import requests +import time +import os + +class AssistApiClient: + BASE_URL = "https://assist.org/api" + + def __init__(self, api_key=None): + self.api_key = api_key or os.getenv("ASSIST_API_KEY") + if not self.api_key: + raise ValueError("ASSIST_API_KEY not found in environment variables.") + self.headers = {"Authorization": f"Bearer {self.api_key}"} + self.institution_cache = {} + self.agreement_cache = {} + + def _make_request(self, endpoint, params=None, use_cache=True, cache_key=None, cache_type=None): + if use_cache and cache_key: + if cache_type == 'institution' and cache_key in self.institution_cache: + print(f"Cache hit for institution: {cache_key}") + return self.institution_cache[cache_key] + elif cache_type == 'agreement' and cache_key in self.agreement_cache: + print(f"Cache hit for agreement: {cache_key}") + return self.agreement_cache[cache_key] + + url = f"{self.BASE_URL}/{endpoint}" + max_retries = 3 + for attempt in range(max_retries): + try: + response = requests.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + if use_cache and cache_key: + if cache_type == 'institution': + self.institution_cache[cache_key] = data + elif cache_type == 'agreement': + self.agreement_cache[cache_key] = data + return data + except requests.exceptions.HTTPError as e: + if e.response.status_code == 429: + print(f"Rate limit exceeded. Retrying in {2 ** attempt} seconds...") + time.sleep(2 ** attempt) + else: + print(f"HTTP error: {e} for URL: {url} with params: {params}") + return None + except requests.exceptions.RequestException as e: + print(f"Request failed: {e} for URL: {url} with params: {params}") + return None + return None + + def get_institutions(self): + return self._make_request("institutions") + + def get_institution_name(self, institution_id): + cache_key = str(institution_id) + if cache_key in self.institution_cache and 'name' in self.institution_cache[cache_key]: + print(f"Cache hit for institution name: {institution_id}") + return self.institution_cache[cache_key]['name'] + + data = self._make_request(f"institutions/{institution_id}", use_cache=True, cache_key=cache_key, cache_type='institution') + return data.get('name') if data else None + + def get_academic_years(self, institution_id): + return self._make_request(f"institutions/{institution_id}/academic-years") + + def get_agreements(self, receiving_institution_id, sending_institution_id, academic_year_id, category_code=None): + cache_key = f"{receiving_institution_id}_{sending_institution_id}_{academic_year_id}_{category_code or 'all'}" + params = { + "receivingInstitutionId": receiving_institution_id, + "sendingInstitutionId": sending_institution_id, + "academicYearId": academic_year_id, + "categoryCode": category_code + } + return self._make_request("agreements", params=params, use_cache=True, cache_key=cache_key, cache_type='agreement') + + def get_agreement_details(self, agreement_key): + return self._make_request(f"agreements/{agreement_key}/content") + +assist_client = AssistApiClient() diff --git a/backend/college_transfer_ai/college_transfer_API.py b/backend/college_transfer_ai/college_transfer_API.py deleted file mode 100644 index fc71d86..0000000 --- a/backend/college_transfer_ai/college_transfer_API.py +++ /dev/null @@ -1,218 +0,0 @@ -import requests -from playwright.sync_api import sync_playwright -from pymongo import MongoClient -import gridfs -import json -import os - -class CollegeTransferAPI: - def __init__(self): - self.base_url = "https://assist.org/api/" - - def get_academic_years(self): - url = self.base_url + "AcademicYears" - response = requests.get(url) - - result_academic_years = {} - - academic_years_dict = {} - - if response.status_code == 200: - academic_years = response.json() - result_academic_years = academic_years - else: - raise Exception("Failed to fetch academic years") - - for year in result_academic_years: - academic_year = (str(year['FallYear'] - 1) + "-" + str(year['FallYear'])) - if academic_year == "2025-2026": continue - academic_years_dict[academic_year] = year['Id'] - 1 - - return academic_years_dict - - def get_college_from_id(self, id): - url = "https://assist.org/api/institutions" - - result = requests.get(url) - result_json = {} - try: - json_data = result.json() - result_json = json_data - except ValueError: - print("Request failed") - - institutions_dict = {str(item["id"]): item for item in result_json if "id" in item} - - for k,v in institutions_dict.items(): - if k == str(id): - return v['names'][0]['name'].replace(" ", "_") - - def get_year_from_id(self, id): - url = "https://assist.org/api/AcademicYears" - - result = requests.get(url) - result_json = {} - try: - json_data = result.json() - result_json = json_data - except ValueError: - print("Request failed") - - - for k in result_json: - for idx, value in k.items(): - if idx == "Id" and value == id: - return str(k["FallYear"]) + "-" + str(k["FallYear"] + 1) - - def get_all_majors(self, sending_institution_id, receiving_institution_id, academic_year_id, category_code="major"): - # Define the parameters - # sending_institution_id = 61 - # receiving_institution_id = 79 - # academic_year_id = 75 - # category_code = "major" - - # Define the API endpoint and parameters - - url = f"https://assist.org/api/agreements?receivingInstitutionId={receiving_institution_id}&sendingInstitutionId={sending_institution_id}&academicYearId={academic_year_id}&categoryCode={category_code}" - - result = requests.get(url) - - result_json = {} - - majors_dict = {} - - # Convert the response to JSON and write it to a file - try: - json_data = result.json() - result_json = json_data - except ValueError: - print("Request failed") - - for n,v in result_json.items(): - for major in v: - majors_dict[major["label"]] = major["key"] - - return majors_dict - - def get_major_from_key(self, key): - # Define the parameters - # sending_institution_id = 61 - # receiving_institution_id = 79 - # academic_year_id = 75 - # category_code = "major" - - # Define the API endpoint and parameters - - keyArray = key.split("/") - - sending_institution_id = int(keyArray[1]) - receiving_institution_id = int(keyArray[3]) - academic_year_id = int(keyArray[0]) - - url = f"https://assist.org/api/agreements?receivingInstitutionId={receiving_institution_id}&sendingInstitutionId={sending_institution_id}&academicYearId={academic_year_id}&categoryCode=major" - - result = requests.get(url) - - result_json = {} - - # Convert the response to JSON and write it to a file - try: - json_data = result.json() - result_json = json_data - except ValueError: - print("Request failed") - - for _, c in result_json.items(): - for i in c: - for l, j in i.items(): - if l == "key" and j == key: - return i["label"].replace(" ", "_") - - return "Key Not Found", key - - def get_sending_institutions(self): - - url = "https://assist.org/api/institutions" - - result = requests.get(url) - - result_json = {} - - result_dict_colleges = {} - - try: - json_data = result.json() - result_json = json_data - except ValueError: - print("Something went wrong when getting colleges") - - for institution in result_json: - for name in institution["names"]: - result_dict_colleges[name["name"]] = institution["id"] - - return result_dict_colleges - - def get_receiving_institutions(self, receiving_institution_id): - url = f"https://assist.org/api/institutions/{receiving_institution_id}/agreements" - - result = requests.get(url) - - result_json = {} - - result_dict_non_ccs = {} - - try: - json_data = result.json() - result_json = json_data - except ValueError: - print("Something went wrong when getting colleges") - - for institution in result_json: - result_dict_non_ccs[institution["institutionName"]] = institution["institutionParentId"] - - return result_dict_non_ccs - - - def get_articulation_agreement(self, academic_year_id, sending_institution_id, receiving_institution_id, major_key): - filename = ( - f"{self.get_college_from_id(sending_institution_id)}_to_" - f"{self.get_college_from_id(receiving_institution_id)}_" - f"{self.get_major_from_key(major_key)}_" - f"{self.get_year_from_id(academic_year_id)}.pdf" - ) - - MONGO_URI = os.getenv("MONGO_URI") - - client = MongoClient(MONGO_URI) - db = client["CollegeTransferAICluster"] - fs = gridfs.GridFS(db) - - # Check if file already exists - if fs.find_one({"filename": filename}): - client.close() - return filename - - url = ( - f"https://assist.org/transfer/results?year={academic_year_id}" - f"&institution={sending_institution_id}" - f"&agreement={receiving_institution_id}" - f"&agreementType=to&viewAgreementsOptions=true&view=agreement" - f"&viewBy=major&viewSendingAgreements=false&viewByKey={major_key}" - ) - - with sync_playwright() as p: - browser = p.chromium.launch(headless=True) - page = browser.new_page() - page.goto(url, wait_until="networkidle") - pdf_bytes = page.pdf(format="A4") # Get PDF as bytes - browser.close() - - fs.put(pdf_bytes, filename=filename) - client.close() - - return filename - - - - -api = CollegeTransferAPI() diff --git a/backend/college_transfer_ai/config.py b/backend/college_transfer_ai/config.py new file mode 100644 index 0000000..5cae597 --- /dev/null +++ b/backend/college_transfer_ai/config.py @@ -0,0 +1,50 @@ +import os +import json +import traceback + +def load_configuration(): + env = os.getenv('FLASK_ENV', 'development') + config_filename = f"config.{env}.json" + print(f"--- Loading configuration for environment: {env} from {config_filename} ---") + + config = {} + try: + with open(config_filename, 'r') as f: + config = json.load(f) + print("--- Configuration loaded successfully from JSON file ---") + except FileNotFoundError: + print(f"!!! WARNING: {config_filename} not found. Attempting to load from environment variables.") + except json.JSONDecodeError as e: + print(f"!!! ERROR: Failed to parse {config_filename}: {e}. Attempting to load from environment variables.") + traceback.print_exc() + + env_vars = { + "MONGO_URI": os.getenv("MONGO_URI"), + "ASSIST_API_KEY": os.getenv("ASSIST_API_KEY"), + "GEMINI_API_KEY": os.getenv("GEMINI_API_KEY"), + "FRONTEND_URL": os.getenv("FRONTEND_URL"), + "STRIPE_SECRET_KEY": os.getenv("STRIPE_SECRET_KEY"), + "STRIPE_PUBLISHABLE_KEY": os.getenv("STRIPE_PUBLISHABLE_KEY"), + "STRIPE_WEBHOOK_SECRET": os.getenv("STRIPE_WEBHOOK_SECRET"), + "GOOGLE_CLIENT_ID": os.getenv("GOOGLE_CLIENT_ID") + } + + loaded_from_env = False + for key, value in env_vars.items(): + if value is not None: + config[key] = value + if not loaded_from_env: + print("--- Loading/Overriding configuration from environment variables ---") + loaded_from_env = True + print(f" Loaded {key} from environment.") + + required_keys = ["MONGO_URI", "ASSIST_API_KEY", "GEMINI_API_KEY", "FRONTEND_URL", "STRIPE_SECRET_KEY", "GOOGLE_CLIENT_ID"] + missing_keys = [key for key in required_keys if not config.get(key)] + + if missing_keys: + error_message = f"Missing required configuration keys: {', '.join(missing_keys)}" + print(f"!!! CRITICAL: {error_message}") + raise ValueError(error_message) + + print("--- Final configuration loaded ---") + return config diff --git a/backend/college_transfer_ai/database.py b/backend/college_transfer_ai/database.py new file mode 100644 index 0000000..20db1a4 --- /dev/null +++ b/backend/college_transfer_ai/database.py @@ -0,0 +1,105 @@ +from flask import current_app, g +from pymongo import MongoClient +from pymongo.errors import ConnectionFailure +import gridfs +import os +from urllib.parse import urlparse +import traceback + +client = None +db = None +fs = None +users_collection = None +course_maps_collection = None + +def init_db(app, mongo_uri): + global client, db, fs, users_collection, course_maps_collection + + if client: + print("--- Database already initialized ---") + return + + if not mongo_uri: + print("!!! CRITICAL: MONGO_URI not set in configuration.") + raise ValueError("MONGO_URI is required for database initialization.") + + try: + print(f"--- Attempting MongoDB Connection to URI specified ---") + client = MongoClient(mongo_uri) + client.admin.command('ismaster') + db_name = urlparse(mongo_uri).path.lstrip('/') + if not db_name: + db_name = 'college_transfer_ai_db' + print(f"Warning: Database name not found in MONGO_URI path, defaulting to '{db_name}'.") + db = client[db_name] + fs = gridfs.GridFS(db) + + users_collection = db['users'] + course_maps_collection = db['course_maps'] + + print(f"--- MongoDB Connected & GridFS Initialized (DB: {db_name}) ---") + print(f"--- Collections Initialized: {users_collection.name}, {course_maps_collection.name} ---") + + except ConnectionFailure as e: + print(f"!!! CRITICAL: MongoDB Server not available. Error: {e}") + client = None; db = None; fs = None; users_collection = None; course_maps_collection = None + raise ConnectionError(f"Failed to connect to MongoDB: {e}") from e + except Exception as e: + print(f"!!! CRITICAL: An unexpected error occurred during MongoDB initialization: {e}") + traceback.print_exc() + client = None; db = None; fs = None; users_collection = None; course_maps_collection = None + raise + + +def get_db(): + if 'db' not in g: + if db is None: + raise Exception("Global database not initialized. Ensure init_db() was called successfully at app startup.") + g.db = db + print("--- Attaching global DB to request context 'g' ---") + return g.db + +def get_gridfs(): + if 'fs' not in g: + if fs is None: + raise Exception("Global GridFS not initialized. Ensure init_db() was called successfully at app startup.") + g.fs = fs + print("--- Attaching global GridFS to request context 'g' ---") + return g.fs + +def get_users_collection(): + if 'users_collection' not in g: + if users_collection is None: + raise Exception("Global Users collection not initialized. Ensure init_db() was called successfully.") + g.users_collection = users_collection + print("--- Attaching global Users Collection to request context 'g' ---") + return g.users_collection + +def get_course_maps_collection(): + if 'course_maps_collection' not in g: + if course_maps_collection is None: + raise Exception("Global Course maps collection not initialized. Ensure init_db() was called successfully.") + g.course_maps_collection = course_maps_collection + print("--- Attaching global Course Maps Collection to request context 'g' ---") + return g.course_maps_collection + + +def close_db(e=None): + db_instance = g.pop('db', None) + fs_instance = g.pop('fs', None) + + if db_instance is not None or fs_instance is not None: + print("--- Cleaning up DB/GridFS references from request context 'g' ---") + + +def get_mongo_client(): + MONGO_URI = os.getenv("MONGO_URI") + if not MONGO_URI: + try: + MONGO_URI = current_app.config['APP_CONFIG'].get("MONGO_URI") + if not MONGO_URI: + raise ValueError("MONGO_URI environment variable not set and not found in app config.") + except (RuntimeError, KeyError): + raise ValueError("MONGO_URI environment variable not set and no Flask app context available.") + print("--- Creating new standalone MongoDB client connection (caller must close) ---") + return MongoClient(MONGO_URI) \ No newline at end of file diff --git a/backend/college_transfer_ai/helpers/mongo_helper.py b/backend/college_transfer_ai/helpers/mongo_helper.py new file mode 100644 index 0000000..fd06884 --- /dev/null +++ b/backend/college_transfer_ai/helpers/mongo_helper.py @@ -0,0 +1,8 @@ +from pymongo import MongoClient + +def get_mongo_client(uri): + return MongoClient(uri) + +def close_mongo_client(client): + if client: + client.close() diff --git a/backend/college_transfer_ai/pdf_service.py b/backend/college_transfer_ai/pdf_service.py new file mode 100644 index 0000000..e816eaa --- /dev/null +++ b/backend/college_transfer_ai/pdf_service.py @@ -0,0 +1,83 @@ +import os +import fitz +import io +import traceback +from .database import get_gridfs +from .assist_api_client import AssistApiClient + +class PdfService: + def __init__(self, assist_client: AssistApiClient): + self.assist_client = assist_client + self.fs = get_gridfs() + if self.fs is None: + print("!!! CRITICAL: GridFS not available at PdfService initialization.") + raise ConnectionError("GridFS is not initialized. Cannot proceed with PdfService.") + + def _generate_pdf_filename(self, type_prefix, year_id, sending_id, receiving_id=None, major_key=None): + parts = [type_prefix, str(year_id), str(sending_id)] + if receiving_id is not None: + parts.append(str(receiving_id)) + if major_key: + safe_major_key = major_key.replace("/", "_").replace(" ", "-") + parts.append(safe_major_key) + return "_".join(parts) + ".pdf" + + def _fetch_and_store_pdf(self, filename, api_call_func, *args): + if self.fs.exists({"filename": filename}): + print(f"PDF {filename} already exists in GridFS.") + return filename + + print(f"Fetching PDF content for {filename} from Assist.org...") + pdf_content_response = api_call_func(*args) + + if pdf_content_response is None: + print(f"Failed to fetch PDF content for {filename} (API returned None).") + return None + + if not isinstance(pdf_content_response, bytes): + print(f"API response for {filename} is not bytes, attempting to encode.") + try: + if hasattr(pdf_content_response, 'text'): + pdf_content_response = pdf_content_response.text.encode('utf-8') + elif isinstance(pdf_content_response, str): + pdf_content_response = pdf_content_response.encode('utf-8') + else: + raise TypeError("Unsupported response type for PDF content.") + except Exception as e: + print(f"Error encoding PDF content for {filename}: {e}") + return None + + try: + with io.BytesIO(pdf_content_response) as pdf_stream: + doc = fitz.open(stream=pdf_stream, filetype="pdf") + if not doc.is_pdf or doc.page_count == 0: + print(f"Invalid or empty PDF received for {filename}. Content starts with: {pdf_content_response[:100]}") + return None + doc.close() + + self.fs.put(pdf_content_response, filename=filename, contentType="application/pdf") + print(f"Stored PDF {filename} in GridFS.") + return filename + except fitz.errors.FitzError as fe: + print(f"FitzError validating PDF {filename}: {fe}. Content starts with: {pdf_content_response[:200]}") + return None + except Exception as e: + print(f"Error storing PDF {filename} in GridFS: {e}") + traceback.print_exc() + return None + + def get_articulation_agreement(self, year_id, sending_id, receiving_id, major_key): + filename = self._generate_pdf_filename("agreement", year_id, sending_id, receiving_id, major_key) + return self._fetch_and_store_pdf( + filename, + self.assist_client.get_agreement_details, + major_key + ) + + def get_igetc_courses(self, year_id, sending_institution_id): + filename = self._generate_pdf_filename("igetc", year_id, sending_institution_id) + return self._fetch_and_store_pdf( + filename, + self.assist_client.get_agreement_details, + f"igetc/{year_id}/{sending_institution_id}" + ) diff --git a/backend/college_transfer_ai/routes/__init__.py b/backend/college_transfer_ai/routes/__init__.py new file mode 100644 index 0000000..539621a --- /dev/null +++ b/backend/college_transfer_ai/routes/__init__.py @@ -0,0 +1,6 @@ +from .agreement_pdf_routes import agreement_pdf_bp +from .chat_routes import chat_bp +from .course_map_routes import course_map_bp +from .user_routes import user_bp +from .api_info_routes import api_info_bp +from .igetc_routes import igetc_bp \ No newline at end of file diff --git a/backend/college_transfer_ai/routes/agreement_pdf_routes.py b/backend/college_transfer_ai/routes/agreement_pdf_routes.py new file mode 100644 index 0000000..34594c6 --- /dev/null +++ b/backend/college_transfer_ai/routes/agreement_pdf_routes.py @@ -0,0 +1,163 @@ +import traceback +import io +import fitz +from flask import Blueprint, jsonify, request, send_file, make_response +from ..database import get_gridfs +from ..pdf_service import PdfService +from ..assist_api_client import assist_client + +agreement_pdf_bp = Blueprint('agreement_pdf_bp', __name__) + +pdf_service_instance = PdfService(assist_client) + +@agreement_pdf_bp.route('/articulation-agreements', methods=['POST']) +def get_articulation_agreements(): + data = request.get_json() + sending_ids = data.get('sending_ids') + receiving_id = data.get('receiving_id') + year_id = data.get('year_id') + major_key = data.get('major_key') + + if not sending_ids or not isinstance(sending_ids, list) or not receiving_id or not year_id or not major_key: + return jsonify({"error": "Missing or invalid parameters (sending_ids list, receiving_id, year_id, major_key)"}), 400 + + results = [] + errors = [] + + for sending_id in sending_ids: + sending_name = assist_client.get_institution_name(sending_id) or f"ID {sending_id}" + try: + key_parts = major_key.split("/") + if len(key_parts) > 1: + key_parts[1] = str(sending_id) + current_major_key = "/".join(key_parts) + else: + print(f"Warning: Unexpected major_key format '{major_key}'. Using original.") + current_major_key = major_key + + pdf_filename = pdf_service_instance.get_articulation_agreement( + year_id, sending_id, receiving_id, current_major_key + ) + + results.append({ + "sendingId": sending_id, + "sendingName": sending_name, + "pdfFilename": pdf_filename + }) + if not pdf_filename: + print(f"PDF generation/fetch failed for Sending ID {sending_id} / Major Key {current_major_key}.") + + except Exception as e: + error_msg = f"Error processing request for Sending ID {sending_id}: {e}" + print(error_msg) + traceback.print_exc() + errors.append(error_msg) + results.append({ + "sendingId": sending_id, + "sendingName": sending_name, + "pdfFilename": None, + "error": str(e) + }) + + if not results and errors: + return jsonify({"error": "Failed to process any agreement requests.", "details": errors}), 500 + elif errors: + has_success = any(item.get('pdfFilename') for item in results if not item.get('error')) + status_code = 207 if has_success else 500 + message = "Partial success fetching agreements." if has_success else "Failed to fetch any agreements." + print(f"{message} Errors: {errors}") + return jsonify({"agreements": results, "warnings": errors}), status_code + elif not any(item.get('pdfFilename') for item in results): + return jsonify({"agreements": results, "message": "No articulation agreements found or generated for the selected criteria."}), 200 + else: + return jsonify({"agreements": results}), 200 + + +@agreement_pdf_bp.route('/pdf-images/', methods=['GET']) +def get_pdf_images(filename): + fs = get_gridfs() + if fs is None: + print("Error: GridFS not available when requested in get_pdf_images.") + return jsonify({"error": "Storage service not available."}), 503 + + try: + existing_images_cursor = fs.find( + {"metadata.original_pdf": filename, "contentType": "image/png"}, + sort=[("metadata.page_number", 1)] + ) + existing_images = list(existing_images_cursor) + + if existing_images: + image_filenames = [img.filename for img in existing_images] + print(f"Found {len(image_filenames)} existing images for {filename} (sorted)") + return jsonify({"image_filenames": image_filenames}) + + + print(f"Generating images for {filename}...") + grid_out = fs.find_one({"filename": filename}) + if not grid_out: + return jsonify({"error": f"PDF file '{filename}' not found in storage."}), 404 + + pdf_data = grid_out.read() + doc = fitz.open(stream=pdf_data, filetype="pdf") + image_filenames = [] + zoom = 2 + mat = fitz.Matrix(zoom, zoom) + generated_files_metadata = [] + + for i, page in enumerate(doc): + try: + pix = page.get_pixmap(matrix=mat) + img_bytes = pix.tobytes("png") + image_filename = f"{filename}_page_{i}.png" + fs.put( + img_bytes, + filename=image_filename, + contentType="image/png", + metadata={"original_pdf": filename, "page_number": i} + ) + generated_files_metadata.append({"filename": image_filename, "page_number": i}) + except Exception as page_err: + print(f"Error processing page {i} for {filename}: {page_err}") + + doc.close() + + generated_files_metadata.sort(key=lambda x: x["page_number"]) + image_filenames = [item["filename"] for item in generated_files_metadata] + + print(f"Stored {len(image_filenames)} images for {filename}") + return jsonify({"image_filenames": image_filenames}) + + except Exception as e: + print(f"Error getting/generating images for {filename}: {e}") + traceback.print_exc() + return jsonify({"error": f"Failed to process PDF '{filename}': {str(e)}"}), 500 + + +@agreement_pdf_bp.route('/image/', methods=['GET']) +def get_image(filename): + fs = get_gridfs() + if fs is None: + print("Error: GridFS not available when requested in get_image.") + return jsonify({"error": "Storage service not available."}), 503 + + grid_out = None + try: + grid_out = fs.find_one({"filename": filename}) + if not grid_out: + return jsonify({"error": "Image not found"}), 404 + + image_data_stream = io.BytesIO(grid_out.read()) + response = make_response(send_file( + image_data_stream, + mimetype=grid_out.contentType or 'image/png', + as_attachment=False, + download_name=grid_out.filename + )) + response.headers['Cache-Control'] = 'public, immutable, max-age=31536000' + return response + + except Exception as e: + print(f"Error serving image {filename}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to serve image"}), 500 diff --git a/backend/college_transfer_ai/routes/api_info_routes.py b/backend/college_transfer_ai/routes/api_info_routes.py new file mode 100644 index 0000000..8fde5fe --- /dev/null +++ b/backend/college_transfer_ai/routes/api_info_routes.py @@ -0,0 +1,159 @@ +import traceback +from flask import Blueprint, jsonify, request, current_app +from ..assist_api_client import assist_client +from ..utils import calculate_intersection + +api_info_bp = Blueprint('api_info_bp', __name__) + +@api_info_bp.route('/institutions', methods=['GET']) +def get_institutions(): + try: + institutions = assist_client.get_institutions() + if institutions: + return jsonify(institutions), 200 + else: + return jsonify({"error": "Failed to fetch institutions from Assist.org"}), 502 + except Exception as e: + print(f"Error fetching institutions: {e}") + traceback.print_exc() + return jsonify({"error": "An internal error occurred"}), 500 + +@api_info_bp.route('/academic-years', methods=['GET']) +def get_academic_years_route(): + sending_id_str = request.args.get('sendingId') + receiving_id_str = request.args.get('receivingId') + + if not sending_id_str or not receiving_id_str: + return jsonify({"error": "Missing sendingId or receivingId parameter"}), 400 + + try: + sending_ids = [int(id_str) for id_str in sending_id_str.split(',')] + receiving_id = int(receiving_id_str) + except ValueError: + return jsonify({"error": "Invalid ID format. IDs must be integers."}), 400 + + all_results = [] + errors = [] + warnings = [] + + for s_id in sending_ids: + try: + years = assist_client.get_academic_years(s_id) + if years: + all_results.append(years) + else: + warnings.append(f"No academic years found for sending institution {s_id}.") + except Exception as e: + error_msg = f"Error fetching academic years for sending institution {s_id}: {e}" + print(error_msg) + traceback.print_exc() + errors.append(error_msg) + + try: + receiving_years = assist_client.get_academic_years(receiving_id) + if receiving_years: + all_results.append(receiving_years) + else: + warnings.append(f"No academic years found for receiving institution {receiving_id}.") + except Exception as e: + error_msg = f"Error fetching academic years for receiving institution {receiving_id}: {e}" + print(error_msg) + traceback.print_exc() + errors.append(error_msg) + + if errors and not all_results: + return jsonify({"error": "Failed to fetch any academic years.", "details": errors}), 502 + + common_years = calculate_intersection(all_results) + + response_data = {"years": common_years} + status_code = 200 + + if warnings: + response_data["warnings"] = warnings + if not common_years: + status_code = 207 + if errors: + response_data["errors"] = errors + status_code = 207 if common_years else 500 + + if not common_years and not warnings and not errors: + response_data["message"] = "No common academic years found for the selected combination." + + return jsonify(response_data), status_code + + +@api_info_bp.route('/majors', methods=['GET']) +def get_majors_route(): + sending_id_str = request.args.get('sendingId') + receiving_id_str = request.args.get('receivingId') + year_id_str = request.args.get('yearId') + + if not sending_id_str or not receiving_id_str or not year_id_str: + return jsonify({"error": "Missing sendingId, receivingId, or yearId parameter"}), 400 + + try: + sending_ids = [int(id_str) for id_str in sending_id_str.split(',')] + receiving_id = int(receiving_id_str) + year_id = int(year_id_str) + except ValueError: + return jsonify({"error": "Invalid ID format. IDs must be integers."}), 400 + + all_majors_results = [] + errors = [] + warnings = [] + + for s_id in sending_ids: + try: + majors = assist_client.get_agreements(receiving_id, s_id, year_id) + if majors and majors.get('reports'): + all_majors_results.append(majors) + else: + warnings.append(f"No majors found for sending institution {s_id} with receiving {receiving_id} for year {year_id}.") + except Exception as e: + error_msg = f"Error fetching majors for sending institution {s_id}: {e}" + print(error_msg) + traceback.print_exc() + errors.append(error_msg) + + if errors and not all_majors_results: + return jsonify({"error": "Failed to fetch any majors.", "details": errors}), 502 + + if not all_majors_results: + response_data = {"majors": [], "message": "No majors found for the selected criteria."} + if warnings: response_data["warnings"] = warnings + if errors: response_data["errors"] = errors + return jsonify(response_data), 200 if not errors else 500 + + combined_majors = {} + if len(sending_ids) == 1 and all_majors_results: + combined_majors = {report['label']: report['key'] for report in all_majors_results[0].get('reports', [])} + else: + major_label_to_keys = {} + for result_set in all_majors_results: + for report in result_set.get('reports', []): + label = report['label'] + key = report['key'] + if label not in major_label_to_keys: + major_label_to_keys[label] = [] + major_label_to_keys[label].append(key) + + for label, keys in major_label_to_keys.items(): + if len(keys) == len(sending_ids): + combined_majors[label] = keys[0] + + response_data = {"majors": combined_majors} + status_code = 200 + + if warnings: + response_data["warnings"] = warnings + if not combined_majors: + status_code = 207 + if errors: + response_data["errors"] = errors + status_code = 207 if combined_majors else 500 + + if not combined_majors and not warnings and not errors: + response_data["message"] = "No common majors found across all selected sending institutions for the specified receiving institution and year." + + return jsonify(response_data), status_code diff --git a/backend/college_transfer_ai/routes/chat_routes.py b/backend/college_transfer_ai/routes/chat_routes.py new file mode 100644 index 0000000..e86d196 --- /dev/null +++ b/backend/college_transfer_ai/routes/chat_routes.py @@ -0,0 +1,268 @@ +import traceback +import requests +import json +from flask import Blueprint, jsonify, request, current_app +from datetime import datetime, timedelta, time, timezone + +import google.generativeai as genai +from google.generativeai.types import HarmCategory, HarmBlockThreshold, Tool, FunctionDeclaration +from google.generativeai.types import content_types + +from ..utils import verify_google_token, get_or_create_user, check_and_update_usage +from ..database import get_gridfs, get_db + +chat_bp = Blueprint('chat_bp', __name__) + +FREE_TIER_LIMIT = 10 +PREMIUM_TIER_LIMIT = 50 + +gemini_model = None +perplexity_api_key = None + +search_web_func = FunctionDeclaration( + name="search_web", + description="Search the web specifically for course prerequisite information. Use this tool when generating an educational plan to find prerequisites for a given course at a specific institution. If a prerequisite course is found, use this tool again to find *its* prerequisites, continuing recursively until no further prerequisites are found or a reasonable depth is reached (e.g., 2-3 levels deep). Only use this for finding prerequisite chains.", + parameters={ + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The search query, which should include the course code (e.g., 'MATH 101') and the specific institution name (e.g., 'Example University') to find its prerequisites. Example: 'prerequisites for MATH 101 at Example University'" + } + }, + "required": ["query"] + } +) +search_tool = Tool(function_declarations=[search_web_func]) + +def init_chat_routes(app): + global gemini_model, perplexity_api_key + + config = app.config['APP_CONFIG'] + google_api_key = config.get('GOOGLE_API_KEY') + perplexity_api_key = config.get('PERPLEXITY_API_KEY') + + try: + with app.app_context(): + fs_instance = get_gridfs() + if fs_instance: + print("--- GridFS accessed successfully in init_chat_routes (within context) ---") + else: + print("!!! WARNING: get_gridfs() returned None in init_chat_routes.") + except Exception as e: + print(f"Error during chat routes initialization related to DB/GridFS: {e}") + + if not google_api_key: + print("Warning: GOOGLE_API_KEY not set. Gemini features will be disabled.") + return + if not perplexity_api_key: + print("Warning: PERPLEXITY_API_KEY not set. Web search tool will be disabled.") + + try: + genai.configure(api_key=google_api_key) + model_tools = [search_tool] if perplexity_api_key else None + gemini_model = genai.GenerativeModel( + 'gemini-1.5-flash', + tools=model_tools + ) + print(f"--- Gemini Initialized Successfully {'with Web Search Tool' if model_tools else ''} ---") + except Exception as e: + print(f"!!! Gemini Initialization Error: {e}") + gemini_model = None + + +def call_perplexity_api(query: str) -> dict: + if not perplexity_api_key: + print("Error: Perplexity API key not configured.") + return {"error": "Web search tool not configured."} + + url = "https://api.perplexity.ai/chat/completions" + payload = { + "model": "sonar", + "messages": [ + {"role": "system", "content": "You are an AI assistant specialized in finding and extracting course prerequisite information from web searches. Provide only the prerequisite course codes (e.g., MATH 100, ENGL 1A) or state 'None' if no prerequisites are found."}, + {"role": "user", "content": query} + ] + } + headers = { + "accept": "application/json", + "content-type": "application/json", + "authorization": f"Bearer {perplexity_api_key}" + } + + try: + print(f"--- Calling Perplexity API with query: {query} ---") + response = requests.post(url, json=payload, headers=headers, timeout=30) + response.raise_for_status() + result = response.json() + print(f"--- Perplexity API Response Status: {response.status_code} ---") + + if result.get("choices") and len(result["choices"]) > 0: + content = result["choices"][0].get("message", {}).get("content") + if content: + return {"result": content} + else: + print("Warning: Perplexity response missing content.") + return {"result": "No prerequisite information found in search results."} + else: + print("Warning: Perplexity response format unexpected:", result) + return {"error": "Unexpected response format from Perplexity."} + + except requests.exceptions.RequestException as e: + print(f"Error calling Perplexity API: {e}") + return {"error": f"Failed to connect to web search service: {e}"} + except Exception as e: + print(f"Unexpected error during Perplexity call: {e}") + traceback.print_exc() + return {"error": "An unexpected error occurred during web search."} + + +@chat_bp.route('/chat', methods=['POST']) +def chat_endpoint(): + fs_request = get_gridfs() + if fs_request is None: + print("Error: GridFS not available for this request.") + return jsonify({"error": "Storage service unavailable"}), 500 + + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + + if not GOOGLE_CLIENT_ID: + print("Error: GOOGLE_CLIENT_ID not configured.") + return jsonify({"error": "Server configuration error"}), 500 + if not gemini_model: + print("Error: Gemini model not initialized.") + return jsonify({"error": "Chat service unavailable"}), 500 + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + user_data = get_or_create_user(user_info) + + if not check_and_update_usage(user_data): + now = datetime.now(timezone.utc) + tomorrow = now.date() + timedelta(days=1) + tomorrow_midnight_utc = datetime.combine(tomorrow, time(0, 0), tzinfo=timezone.utc) + reset_time_str = tomorrow_midnight_utc.strftime('%Y-%m-%d %H:%M:%S %Z') + limit = PREMIUM_TIER_LIMIT if user_data.get('tier') == 'premium' else FREE_TIER_LIMIT + return jsonify({ + "error": f"Usage limit ({limit} requests/day) exceeded for your tier ('{user_data.get('tier')}'). Please try again after {reset_time_str}." + }), 429 + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as usage_err: + print(f"Error during usage check: {usage_err}") + traceback.print_exc() + return jsonify({"error": "Could not verify usage limits."}), 500 + + data = request.get_json() + if not data or 'new_message' not in data: + return jsonify({"error": "Missing 'new_message' in request body"}), 400 + + new_message_text = data['new_message'] + history = data.get('history', []) + image_filenames = data.get('image_filenames', []) + + prompt_parts = [] + if image_filenames: + print(f"Processing {len(image_filenames)} images for chat...") + image_mime_type = "image/png" + for img_filename in image_filenames: + try: + grid_out = fs_request.find_one({"filename": img_filename}) + if grid_out: + image_data = grid_out.read() + prompt_parts.append({"mime_type": image_mime_type, "data": image_data}) + else: + print(f"Warning: Image '{img_filename}' not found in GridFS.") + except Exception as img_err: + print(f"Error reading image '{img_filename}' from GridFS: {img_err}") + + prompt_parts.append(new_message_text) + + try: + print("Sending initial request to Gemini...") + api_history = [] + for msg in history: + role = 'model' if msg.get('role') == 'assistant' else msg.get('role') + if role in ['user', 'model'] and msg.get('content'): + api_history.append({'role': role, 'parts': [msg['content']]}) + + chat_session = gemini_model.start_chat(history=api_history) + response = chat_session.send_message( + prompt_parts, + stream=False, + safety_settings={ + HarmCategory.HARM_CATEGORY_HARASSMENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + HarmCategory.HARM_CATEGORY_HATE_SPEECH: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE, + } + ) + + while response.candidates and response.candidates[0].content.parts and isinstance(response.candidates[0].content.parts[0], genai.types.FunctionCall): + function_call = response.candidates[0].content.parts[0].function_call + print(f"--- Gemini requested Function Call: {function_call.name} ---") + + if function_call.name == "search_web": + query = function_call.args.get("query") + if not query: + print("Error: Gemini function call 'search_web' missing 'query' argument.") + function_response_part = content_types.FunctionResponse( + name="search_web", + response={"error": "Missing 'query' argument in function call."} + ) + else: + search_results = call_perplexity_api(query) + function_response_part = content_types.FunctionResponse( + name="search_web", + response=search_results + ) + + print(f"--- Sending Function Response back to Gemini for {function_call.name} ---") + response = chat_session.send_message(content_types.to_content(function_response_part), stream=False) + + + else: + print(f"Error: Unknown function call requested by Gemini: {function_call.name}") + function_response_part = content_types.FunctionResponse( + name=function_call.name, + response={"error": f"Function '{function_call.name}' is not implemented."} + ) + response = chat_session.send_message(content_types.to_content(function_response_part), stream=False) + + if not response.candidates or not response.candidates[0].content.parts: + print("Gemini response blocked or empty after processing. Feedback:", response.prompt_feedback if hasattr(response, 'prompt_feedback') else "N/A") + try: + safety_feedback = response.prompt_feedback.safety_ratings if hasattr(response, 'prompt_feedback') and hasattr(response.prompt_feedback, 'safety_ratings') else "No feedback available." + except Exception as feedback_err: + safety_feedback = f"Error accessing feedback: {feedback_err}" + return jsonify({"error": "Response blocked due to safety settings or empty response.", "details": str(safety_feedback)}), 400 + + reply_text = "" + try: + if hasattr(response, 'text'): + reply_text = response.text + elif response.candidates and response.candidates[0].content.parts: + reply_text = "".join(part.text for part in response.candidates[0].content.parts if hasattr(part, 'text')) + + if not reply_text: + print("Warning: Could not extract text from final Gemini response.") + return jsonify({"error": "AI assistant returned an empty reply."}), 500 + + except AttributeError as e: + print(f"Error accessing final response text: {e}") + print("Final Gemini Response Object:", response) + return jsonify({"error": "Failed to parse final AI response."}), 500 + + print("Received final reply from Gemini.") + return jsonify({"reply": reply_text}) + + except Exception as e: + print(f"Error during Gemini interaction: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to get response from AI assistant."}), 500 diff --git a/backend/college_transfer_ai/routes/course_map_routes.py b/backend/college_transfer_ai/routes/course_map_routes.py new file mode 100644 index 0000000..a04bc09 --- /dev/null +++ b/backend/college_transfer_ai/routes/course_map_routes.py @@ -0,0 +1,258 @@ +import uuid +import traceback +from flask import Blueprint, jsonify, request, current_app +from bson.objectid import ObjectId +from datetime import datetime, timezone + +from ..utils import verify_google_token, get_or_create_user +from ..database import get_course_maps_collection + +course_map_bp = Blueprint('course_map_bp', __name__) + +@course_map_bp.route('/course-maps', methods=['POST']) +def save_course_map(): + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + course_maps_collection = get_course_maps_collection() + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + user_data = get_or_create_user(user_info) + google_user_id = user_data['google_user_id'] + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as usage_err: + print(f"Error during auth/user check for saving map: {usage_err}") + traceback.print_exc() + return jsonify({"error": "Could not verify user or usage limits."}), 500 + + data = request.get_json() + nodes = data.get('nodes') + edges = data.get('edges') + map_name = data.get('name', 'Untitled Course Map') + + if nodes is None or edges is None: + return jsonify({"error": "Missing 'nodes' or 'edges' in request body"}), 400 + + try: + map_id = str(uuid.uuid4()) + map_document = { + "_id": map_id, + "google_user_id": google_user_id, + "name": map_name, + "nodes": nodes, + "edges": edges, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc) + } + course_maps_collection.insert_one(map_document) + print(f"Course map '{map_name}' ({map_id}) saved for user {google_user_id}") + return jsonify({"message": "Course map saved successfully", "map_id": map_id}), 201 + + except Exception as e: + print(f"Error saving course map for user {google_user_id}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to save course map"}), 500 + +@course_map_bp.route('/course-maps', methods=['GET']) +def get_user_course_maps(): + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + course_maps_collection = get_course_maps_collection() + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + google_user_id = user_info['sub'] + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error during authentication for getting maps: {e}") + traceback.print_exc() + return jsonify({"error": "Authentication failed."}), 500 + + try: + user_maps = list(course_maps_collection.find( + {"google_user_id": google_user_id}, + {"nodes": 0, "edges": 0} + ).sort("updated_at", -1)) + + print(f"Found {len(user_maps)} course maps for user {google_user_id}") + return jsonify(user_maps), 200 + + except Exception as e: + print(f"Error fetching course maps for user {google_user_id}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to fetch course maps"}), 500 + +@course_map_bp.route('/course-map/', methods=['GET']) +def get_course_map_details(map_id): + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + course_maps_collection = get_course_maps_collection() + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + google_user_id = user_info['sub'] + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error during authentication for getting map details: {e}") + traceback.print_exc() + return jsonify({"error": "Authentication failed."}), 500 + + try: + course_map = course_maps_collection.find_one({"_id": map_id}) + + if not course_map: + return jsonify({"error": "Course map not found"}), 404 + + if course_map.get("google_user_id") != google_user_id: + print(f"Authorization failed: User {google_user_id} tried to access map {map_id} owned by {course_map.get('google_user_id')}") + return jsonify({"error": "Not authorized to access this course map"}), 403 + + print(f"Fetched details for course map {map_id}") + return jsonify(course_map), 200 + + except Exception as e: + print(f"Error fetching course map details for map {map_id}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to fetch course map details"}), 500 + +@course_map_bp.route('/course-map/', methods=['PUT']) +def update_course_map(map_id): + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + course_maps_collection = get_course_maps_collection() + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + google_user_id = user_info['sub'] + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error during authentication for updating map: {e}") + traceback.print_exc() + return jsonify({"error": "Authentication failed."}), 500 + + data = request.get_json() + nodes = data.get('nodes') + edges = data.get('edges') + map_name = data.get('name') + + if nodes is None and edges is None and map_name is None: + return jsonify({"error": "No update data provided (nodes, edges, or name)"}), 400 + + try: + existing_map = course_maps_collection.find_one({"_id": map_id}) + if not existing_map: + return jsonify({"error": "Course map not found"}), 404 + if existing_map.get("google_user_id") != google_user_id: + return jsonify({"error": "Not authorized to update this course map"}), 403 + + except Exception as e: + print(f"Error finding map {map_id} for update: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to find course map for update"}), 500 + + try: + update_fields = {"updated_at": datetime.now(timezone.utc)} + if nodes is not None: update_fields["nodes"] = nodes + if edges is not None: update_fields["edges"] = edges + if map_name is not None: update_fields["name"] = map_name + + result = course_maps_collection.update_one( + {"_id": map_id}, + {"$set": update_fields} + ) + + if result.matched_count == 0: + return jsonify({"error": "Course map not found during update"}), 404 + + print(f"Course map {map_id} updated successfully by user {google_user_id}") + return jsonify({"message": "Course map updated successfully"}), 200 + + except Exception as e: + print(f"Error updating course map {map_id}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to update course map"}), 500 + +@course_map_bp.route('/course-map/', methods=['DELETE']) +def delete_course_map(map_id): + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + course_maps_collection = get_course_maps_collection() + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + google_user_id = user_info['sub'] + + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error during authentication for deleting map: {e}") + traceback.print_exc() + return jsonify({"error": "Authentication failed."}), 500 + + try: + existing_map = course_maps_collection.find_one({"_id": map_id}) + if not existing_map: + return jsonify({"error": "Course map not found"}), 404 + if existing_map.get("google_user_id") != google_user_id: + return jsonify({"error": "Not authorized to delete this course map"}), 403 + + except Exception as e: + print(f"Error finding map {map_id} for deletion: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to find course map for deletion"}), 500 + + try: + result = course_maps_collection.delete_one({"_id": map_id}) + + if result.deleted_count == 0: + return jsonify({"error": "Course map not found during deletion"}), 404 + + print(f"Course map {map_id} deleted successfully by user {google_user_id}") + return jsonify({"message": "Course map deleted successfully"}), 200 + + except Exception as e: + print(f"Error deleting course map {map_id}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to delete course map"}), 500 diff --git a/backend/college_transfer_ai/routes/igetc_routes.py b/backend/college_transfer_ai/routes/igetc_routes.py new file mode 100644 index 0000000..6cdc8d7 --- /dev/null +++ b/backend/college_transfer_ai/routes/igetc_routes.py @@ -0,0 +1,54 @@ +import traceback +from flask import Blueprint, jsonify, request, current_app +from ..pdf_service import PdfService +from ..utils import verify_google_token +from ..assist_api_client import assist_client + +igetc_bp = Blueprint('igetc_bp', __name__) + +pdf_service = PdfService(assist_client) + +@igetc_bp.route('/igetc-agreement', methods=['GET']) +def get_igetc_agreement(): + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + if not user_info: + raise ValueError("Invalid token or user not found.") + except ValueError as auth_err: + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error during IGETC auth check: {e}") + traceback.print_exc() + return jsonify({"error": "Authentication failed."}), 500 + + sending_institution_id = request.args.get('sendingId') + academic_year_id = request.args.get('academicYearId') + + if not sending_institution_id or not academic_year_id: + return jsonify({"error": "Missing sendingId or academicYearId parameter"}), 400 + + try: + pdf_filename = pdf_service.get_igetc_courses( + int(academic_year_id), int(sending_institution_id) + ) + + if pdf_filename: + return jsonify({"pdfFilename": pdf_filename}), 200 + else: + return jsonify({"error": "IGETC agreement PDF not found or could not be generated/saved."}), 404 + + except ValueError: + return jsonify({"error": "Invalid ID format. IDs must be integers."}), 400 + except Exception as e: + print(f"Error processing IGETC request for {sending_institution_id} / {academic_year_id}: {e}") + traceback.print_exc() + return jsonify({"error": "Failed to process IGETC agreement request"}), 500 diff --git a/backend/college_transfer_ai/routes/stripe_routes.py b/backend/college_transfer_ai/routes/stripe_routes.py new file mode 100644 index 0000000..5ef8eea --- /dev/null +++ b/backend/college_transfer_ai/routes/stripe_routes.py @@ -0,0 +1,314 @@ +import stripe +import traceback +from flask import Blueprint, jsonify, request, current_app +from bson.objectid import ObjectId +from datetime import datetime, timezone + +from ..utils import verify_google_token, get_or_create_user +from ..database import get_users_collection + +stripe_bp = Blueprint('stripe_bp', __name__) + +@stripe_bp.route('/create-checkout-session', methods=['POST']) +def create_checkout_session(): + config = current_app.config['APP_CONFIG'] + STRIPE_PRICE_ID = config.get('STRIPE_PRICE_ID') + FRONTEND_URL = config.get('FRONTEND_URL') + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + STRIPE_SECRET_KEY = config.get('STRIPE_SECRET_KEY') + + if not STRIPE_PRICE_ID: + return jsonify({"error": "Stripe Price ID not configured on backend."}), 500 + if not FRONTEND_URL: + return jsonify({"error": "Frontend URL not configured on backend."}), 500 + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + if not STRIPE_SECRET_KEY: + return jsonify({"error": "Stripe Secret Key not configured."}), 500 + + stripe.api_key = STRIPE_SECRET_KEY + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + user_data = get_or_create_user(user_info) + google_user_id = user_data['google_user_id'] + mongo_user_id = str(user_data['_id']) + + print(f"Creating checkout session for user: {google_user_id} (Mongo ID: {mongo_user_id})") + print(f'Frontend url: {FRONTEND_URL}') + + checkout_session_params = { + 'line_items': [ + { + 'price': STRIPE_PRICE_ID, + 'quantity': 1, + }, + ], + 'mode': 'subscription', + 'success_url': f'{FRONTEND_URL}/payment-success?session_id={{CHECKOUT_SESSION_ID}}', + 'cancel_url': f'{FRONTEND_URL}/payment-cancel', + 'client_reference_id': mongo_user_id, + 'customer_email': user_data.get('email'), + } + + stripe_customer_id = user_data.get('stripe_customer_id') + + if stripe_customer_id: + checkout_session_params['customer'] = stripe_customer_id + checkout_session_params['customer_update'] = {'name': 'auto', 'address': 'auto'} + else: + checkout_session_params['subscription_data'] = { + 'metadata': { + 'mongo_user_id': mongo_user_id, + 'google_user_id': google_user_id + } + } + + checkout_session = stripe.checkout.Session.create(**checkout_session_params) + + print(f"Stripe session created: {checkout_session.id}") + return jsonify({'sessionId': checkout_session.id}) + + except ValueError as auth_err: + print(f"[/create-checkout-session] Authentication error: {auth_err}") + return jsonify({"error": str(auth_err)}), 401 + except stripe.error.StripeError as e: + print(f"Stripe error creating checkout session: {e}") + user_message = getattr(e, 'user_message', str(e)) + return jsonify({'error': f'Stripe error: {user_message}'}), e.http_status or 500 + except Exception as e: + print(f"Error creating checkout session: {e}") + traceback.print_exc() + return jsonify({'error': f'Internal server error: {str(e)}'}), 500 + +@stripe_bp.route('/stripe-webhook', methods=['POST']) +def stripe_webhook(): + config = current_app.config['APP_CONFIG'] + STRIPE_WEBHOOK_SECRET = config.get('STRIPE_WEBHOOK_SECRET') + STRIPE_SECRET_KEY = config.get('STRIPE_SECRET_KEY') + + if not STRIPE_WEBHOOK_SECRET: + print("Webhook Error: STRIPE_WEBHOOK_SECRET not set.") + return jsonify({'error': 'Webhook secret not configured'}), 500 + if not STRIPE_SECRET_KEY: + print("Webhook Error: STRIPE_SECRET_KEY not set.") + return jsonify({'error': 'Stripe secret key not configured'}), 500 + + stripe.api_key = STRIPE_SECRET_KEY + users_collection = get_users_collection() + + payload = request.data + sig_header = request.headers.get('Stripe-Signature') + event = None + + print("--- Stripe Webhook Received ---") + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, STRIPE_WEBHOOK_SECRET + ) + print(f"Webhook Event Type: {event['type']}") + except ValueError as e: + print(f"Webhook Error: Invalid payload - {e}") + return jsonify({'error': 'Invalid payload'}), 400 + except stripe.error.SignatureVerificationError as e: + print(f"Webhook Error: Invalid signature - {e}") + return jsonify({'error': 'Invalid signature'}), 400 + except Exception as e: + print(f"Webhook Error: Unexpected error constructing event - {e}") + return jsonify({'error': 'Webhook construction error'}), 500 + + try: + event_type = event['type'] + event_data = event['data']['object'] + + if event_type == 'checkout.session.completed': + session = event_data + mongo_user_id = session.get('client_reference_id') + stripe_customer_id = session.get('customer') + stripe_subscription_id = session.get('subscription') + + print(f"Checkout session completed for Mongo User ID: {mongo_user_id}") + print(f" Stripe Customer ID: {stripe_customer_id}") + print(f" Stripe Subscription ID: {stripe_subscription_id}") + + if not mongo_user_id or not stripe_customer_id or not stripe_subscription_id: + print("Webhook Error: Missing required data in checkout.session.completed event.") + return jsonify({'error': 'Missing data in event'}), 400 + + try: + update_result = users_collection.update_one( + {"_id": ObjectId(mongo_user_id)}, + {"$set": { + "stripe_customer_id": stripe_customer_id, + "stripe_subscription_id": stripe_subscription_id, + "subscription_status": "processing" + }} + ) + if update_result.matched_count == 0: + print(f"Webhook Error: User not found for Mongo ID: {mongo_user_id} during checkout completion.") + else: + print(f"User {mongo_user_id} linked with Stripe IDs (status: processing).") + except Exception as e: + print(f"Webhook Error: DB error linking Stripe IDs for user {mongo_user_id}: {e}") + traceback.print_exc() + return jsonify({'error': 'Internal server error linking user'}), 500 + + elif event_type in ['customer.subscription.deleted', 'customer.subscription.updated']: + subscription = event_data + stripe_subscription_id = subscription.id + subscription_status = subscription.status + cancel_at_period_end = subscription.cancel_at_period_end + + print(f"Subscription update/deleted event for Sub ID: {stripe_subscription_id}") + print(f" Status: {subscription_status}, Cancel at Period End: {cancel_at_period_end}") + + update_data = { + "subscription_status": subscription_status, + } + if subscription_status == 'canceled' or cancel_at_period_end: + print(f"Downgrading user associated with subscription {stripe_subscription_id}") + update_data["tier"] = "free" + update_data["subscription_expires"] = None + if subscription_status != 'canceled': + update_data["subscription_status"] = "ending" + elif subscription_status == 'active' and not cancel_at_period_end: + subscription_expires_ts = subscription.current_period_end + subscription_expires_dt = datetime.fromtimestamp(subscription_expires_ts, tz=timezone.utc) if subscription_expires_ts else None + update_data["subscription_expires"] = subscription_expires_dt + update_data["tier"] = "premium" + + update_result = users_collection.update_one( + {"stripe_subscription_id": stripe_subscription_id}, + {"$set": update_data} + ) + if update_result.matched_count == 0: + print(f"Webhook Warning: No user found for subscription ID {stripe_subscription_id} during update/delete.") + else: + print(f"User associated with {stripe_subscription_id} status updated.") + + + elif event_type == 'invoice.payment_succeeded': + print("Invoice payment succeeded event received.") + invoice = event_data + stripe_customer_id = invoice.get('customer') + billing_reason = invoice.billing_reason + + print("Invoice details:") + print(f" Customer ID (from invoice): {stripe_customer_id}") + print(f" Billing Reason: {billing_reason}") + + if stripe_customer_id: + print(f"Attempting to find user by Stripe Customer ID: {stripe_customer_id}") + try: + user_doc = users_collection.find_one({"stripe_customer_id": stripe_customer_id}) + + if user_doc: + stripe_subscription_id = user_doc.get('stripe_subscription_id') + + if stripe_subscription_id: + print(f" User found (Mongo ID: {user_doc['_id']}) with linked Sub ID: {stripe_subscription_id}.") + try: + print(f" Retrieving subscription details for {stripe_subscription_id}...") + subscription = stripe.Subscription.retrieve(stripe_subscription_id) + print(f" Retrieved subscription object: {subscription}") + + subscription_expires_ts = None + subscription_status = subscription.get('status', 'unknown') + + if subscription.get('items') and subscription['items'].get('data'): + first_item = subscription['items']['data'][0] + subscription_expires_ts = first_item.get('current_period_end') + + if subscription_expires_ts is None: + print(f" Webhook Warning: 'current_period_end' not found on first subscription item for {stripe_subscription_id}. Cannot set expiration.") + subscription_expires_dt = None + else: + subscription_expires_dt = datetime.fromtimestamp(subscription_expires_ts, tz=timezone.utc) + + print(f" Subscription Status: {subscription_status}, Expires TS: {subscription_expires_ts}") + + + + update_data = { + "subscription_status": subscription_status, + "subscription_expires": subscription_expires_dt, + "tier": "premium" + } + + if billing_reason in ['subscription_create', 'subscription_cycle']: + print(f" Subscription payment ({billing_reason}). Resetting usage.") + update_data["requests_used_this_period"] = 0 + update_data["period_start_date"] = datetime.now(timezone.utc) + + update_result = users_collection.update_one( + {"stripe_subscription_id": stripe_subscription_id}, + {"$set": update_data} + ) + if update_result.matched_count > 0: + print(f" Successfully updated subscription details/tier for user associated with {stripe_subscription_id}.") + else: + print(f"Webhook Warning: User update failed for subscription {stripe_subscription_id} during {billing_reason} update, even after finding user.") + return jsonify({'error': 'Internal server error updating user'}), 500 + + except stripe.error.StripeError as sub_err: + print(f"Webhook Error: Failed to retrieve Stripe subscription {stripe_subscription_id} during {billing_reason}: {sub_err}") + return jsonify({'error': 'Stripe API error retrieving subscription'}), 500 + except Exception as e: + print(f"Webhook Error: Unexpected error updating user after {billing_reason} for sub {stripe_subscription_id}: {e}") + traceback.print_exc() + return jsonify({'error': 'Internal server error processing subscription'}), 500 + else: + print(f"Webhook Info: User found for customer {stripe_customer_id}, but stripe_subscription_id not linked yet. Requesting retry.") + return jsonify({'error': 'Subscription data not ready, please retry'}), 503 + else: + print(f"Webhook Info: No user found in DB for Stripe Customer ID: {stripe_customer_id}. Checkout session likely not processed yet. Requesting retry.") + return jsonify({'error': 'User data not ready, please retry'}), 503 + + except Exception as db_err: + print(f"Webhook Error: Database error finding user by customer ID {stripe_customer_id}: {db_err}") + traceback.print_exc() + return jsonify({'error': 'Internal server error finding user'}), 500 + else: + print(f"Webhook Error: Skipping invoice.payment_succeeded because 'customer' ID was missing from the invoice object.") + return jsonify({'error': 'Missing customer ID in invoice event'}), 400 + + elif event_type == 'invoice.payment_failed': + invoice = event_data + stripe_subscription_id = invoice.get('subscription') + if stripe_subscription_id: + print(f"Invoice payment failed for subscription {stripe_subscription_id}.") + try: + subscription = stripe.Subscription.retrieve(stripe_subscription_id) + subscription_status = subscription.status + update_result = users_collection.update_one( + {"stripe_subscription_id": stripe_subscription_id}, + {"$set": {"subscription_status": subscription_status}} + ) + if update_result.matched_count > 0: + print(f"Updated subscription status to '{subscription_status}' for user associated with {stripe_subscription_id}.") + else: + print(f"Webhook Warning: User not found for subscription {stripe_subscription_id} during payment failure update.") + except stripe.error.StripeError as sub_err: + print(f"Webhook Error: Failed to retrieve subscription {stripe_subscription_id} after payment failure: {sub_err}") + except Exception as e: + print(f"Webhook Error: Unexpected error updating user after payment failure: {e}") + traceback.print_exc() + + else: + pass + + except KeyError as e: + print(f"Webhook Error: Missing expected key in event data - {e}") + return jsonify({'error': f'Missing key in event data: {e}'}), 400 + except Exception as e: + print(f"Webhook Error: Error handling event {event.get('type', 'N/A')} - {e}") + traceback.print_exc() + return jsonify({'error': 'Internal server error handling webhook'}), 500 + + print("Webhook processing complete, acknowledging event.") + return jsonify({'success': True}), 200 diff --git a/backend/college_transfer_ai/routes/user_routes.py b/backend/college_transfer_ai/routes/user_routes.py new file mode 100644 index 0000000..aaffe1e --- /dev/null +++ b/backend/college_transfer_ai/routes/user_routes.py @@ -0,0 +1,65 @@ +from flask import Blueprint, jsonify, request, current_app +from datetime import datetime, timedelta, time, timezone +import traceback + +from ..utils import verify_google_token, get_or_create_user, FREE_TIER_LIMIT, PREMIUM_TIER_LIMIT + +user_bp = Blueprint('user_bp', __name__) + +@user_bp.route('/user-status', methods=['GET']) +def get_user_status(): + print("--- !!! GET /api/user-status endpoint hit !!! ---") + config = current_app.config['APP_CONFIG'] + GOOGLE_CLIENT_ID = config.get('GOOGLE_CLIENT_ID') + if not GOOGLE_CLIENT_ID: + return jsonify({"error": "Google Client ID not configured."}), 500 + + try: + auth_header = request.headers.get('Authorization') + if not auth_header or not auth_header.startswith('Bearer '): + return jsonify({"error": "Authorization token missing or invalid"}), 401 + token = auth_header.split(' ')[1] + + user_info = verify_google_token(token, GOOGLE_CLIENT_ID) + user_data = get_or_create_user(user_info) + + tier = user_data.get('tier', 'free') + limit = PREMIUM_TIER_LIMIT if tier == 'premium' else FREE_TIER_LIMIT + requests_used = user_data.get('requests_used_this_period', 0) + period_start = user_data.get('period_start_date') + + now = datetime.now(timezone.utc) + reset_time_iso = None + + try: + tomorrow = now.date() + timedelta(days=1) + tomorrow_midnight_utc = datetime.combine(tomorrow, time(0, 0), tzinfo=timezone.utc) + reset_time_iso = tomorrow_midnight_utc.isoformat(timespec='seconds') + except Exception as time_err: + print(f"Error calculating reset time: {time_err}") + reset_time_iso = "Error calculating reset time" + + + display_requests_used = requests_used + if period_start and isinstance(period_start, datetime): + if period_start.tzinfo is None: + period_start = period_start.replace(tzinfo=timezone.utc) + if period_start.date() < now.date(): + display_requests_used = 0 + + print(f"User status requested for {user_data.get('google_user_id')}: Used={display_requests_used}, Limit={limit}, Tier={tier}, Resets={reset_time_iso}") + + return jsonify({ + "tier": tier, + "usageCount": display_requests_used, + "usageLimit": limit, + "resetTime": reset_time_iso + }), 200 + + except ValueError as auth_err: + print(f"[/user-status] Authentication error: {auth_err}") + return jsonify({"error": str(auth_err)}), 401 + except Exception as e: + print(f"Error getting user status: {e}") + traceback.print_exc() + return jsonify({"error": f"Internal server error: {str(e)}"}), 500 \ No newline at end of file diff --git a/backend/college_transfer_ai/utils.py b/backend/college_transfer_ai/utils.py new file mode 100644 index 0000000..263cd18 --- /dev/null +++ b/backend/college_transfer_ai/utils.py @@ -0,0 +1,185 @@ +import os +import traceback +from datetime import datetime, timedelta, time, timezone +from google.oauth2 import id_token +from google.auth.transport import requests as google_requests +from bson.objectid import ObjectId +from .database import get_users_collection + +FREE_TIER_LIMIT = 10 +PREMIUM_TIER_LIMIT = 100 + +def verify_google_token(token, client_id): + try: + idinfo = id_token.verify_oauth2_token(token, google_requests.Request(), client_id) + if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']: + raise ValueError('Wrong issuer.') + return idinfo + except ValueError as ve: + print(f"Google token verification failed: {ve}") + traceback.print_exc() + raise ValueError(f"Invalid token: {ve}") + except Exception as e: + print(f"An unexpected error occurred during Google token verification: {e}") + traceback.print_exc() + raise Exception(f"Token verification failed due to an unexpected error: {e}") + +def get_or_create_user(idinfo): + users_collection = get_users_collection() + + google_user_id = idinfo.get('sub') + if not google_user_id: + raise ValueError("Missing 'sub' (user ID) in token info.") + + user = users_collection.find_one({'google_user_id': google_user_id}) + + if not user: + new_user_data = { + 'google_user_id': google_user_id, + 'email': idinfo.get('email'), + 'name': idinfo.get('name'), + 'tier': 'free', + 'requests_used_this_period': 0, + 'period_start_date': datetime.now(timezone.utc), + 'created_at': datetime.now(timezone.utc), + 'last_login': datetime.now(timezone.utc), + "stripe_customer_id": None, + "stripe_subscription_id": None, + "subscription_status": None, + "subscription_expires": None + } + try: + insert_result = users_collection.insert_one(new_user_data) + user = users_collection.find_one({'_id': insert_result.inserted_id}) + if not user: + raise Exception(f"Failed to retrieve newly created user {google_user_id}") + except Exception as e: + raise Exception(f"Database error creating user: {e}") + else: + update_query = {'last_login': datetime.now(timezone.utc)} + set_fields = {} + if 'tier' not in user: set_fields['tier'] = 'free' + if 'requests_used_this_period' not in user: set_fields['requests_used_this_period'] = 0 + if 'period_start_date' not in user: set_fields['period_start_date'] = datetime.now(timezone.utc) + if 'stripe_customer_id' not in user: set_fields['stripe_customer_id'] = None + if 'stripe_subscription_id' not in user: set_fields['stripe_subscription_id'] = None + if 'subscription_status' not in user: set_fields['subscription_status'] = None + if 'subscription_expires' not in user: set_fields['subscription_expires'] = None + + if set_fields: + update_query.update(set_fields) + users_collection.update_one( + {'google_user_id': google_user_id}, + {'$set': update_query} + ) + user = users_collection.find_one({"google_user_id": google_user_id}) + else: + users_collection.update_one( + {'google_user_id': google_user_id}, + {'$set': {'last_login': update_query['last_login']}} + ) + + return user + +def check_and_update_usage(user_data): + users_collection = get_users_collection() + + tier = user_data.get('tier', 'free') + limit = PREMIUM_TIER_LIMIT if tier == 'premium' else FREE_TIER_LIMIT + requests_used = user_data.get('requests_used_this_period', 0) + period_start = user_data.get('period_start_date') + google_user_id = user_data.get('google_user_id') + + now = datetime.now(timezone.utc) + reset_usage = False + + if period_start and isinstance(period_start, datetime): + if period_start.tzinfo is None: + period_start = period_start.replace(tzinfo=timezone.utc) + + if period_start.date() < now.date(): + requests_used = 0 + period_start = now + reset_usage = True + else: + requests_used = 0 + period_start = now + reset_usage = True + + if requests_used >= limit: + return False + + try: + if reset_usage: + update_fields = { + '$set': { + 'requests_used_this_period': 1, + 'period_start_date': period_start, + 'last_request_timestamp': now + } + } + else: + update_fields = { + '$inc': {'requests_used_this_period': 1}, + '$set': {'last_request_timestamp': now} + } + + result = users_collection.update_one( + {'google_user_id': google_user_id}, + update_fields + ) + if result.matched_count == 0: + raise Exception(f"User {google_user_id} not found during usage update.") + + return True + except Exception as e: + traceback.print_exc() + raise Exception(f"Failed to update usage count: {e}") + +def calculate_intersection(results): + if not results or any(res is None for res in results): + return {} + + valid_results = [res for res in results if isinstance(res, dict) and res] + + if not valid_results: + return {} + + try: + common_ids = set(str(v) for v in valid_results[0].values()) + except Exception as e: + return {} + + for i in range(1, len(valid_results)): + try: + current_ids = set(str(v) for v in valid_results[i].values()) + common_ids.intersection_update(current_ids) + except Exception as e: + continue + + intersection = {} + name_map_source = valid_results[0] + try: + id_to_name_map = {str(v): k for k, v in name_map_source.items()} + except Exception as e: + id_to_name_map = {} + + for common_id in common_ids: + name = id_to_name_map.get(common_id) + if name: + original_id = next((v for v in name_map_source.values() if str(v) == common_id), common_id) + intersection[name] = original_id + else: + found = False + for res in valid_results: + try: + for k, v in res.items(): + if str(v) == common_id: + intersection[k] = v + found = True + break + except Exception: + continue + if found: break + + return intersection \ No newline at end of file diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..8453cf0 --- /dev/null +++ b/backend/run.py @@ -0,0 +1,44 @@ +import os +import traceback +try: + from college_transfer_ai import create_app +except ImportError as e: + print(f"ImportError: {e}") + print("Ensure you are running this script from the 'backend' directory or that the project root is in your PYTHONPATH.") + exit(1) + + +try: + app = create_app() +except Exception as app_create_err: + print(f"!!! CRITICAL: Failed to create Flask app: {app_create_err}") + traceback.print_exc() + exit(1) + + +if __name__ == '__main__': + host = os.environ.get('FLASK_RUN_HOST', '0.0.0.0') + port = int(os.environ.get('FLASK_RUN_PORT', os.environ.get('PORT', 5000))) + debug_str = os.environ.get('FLASK_DEBUG', 'True').lower() + debug = debug_str not in ['false', '0', 'f'] + + print(f"--- Starting Flask Server ---") + print(f"Host: {host}") + print(f"Port: {port}") + print(f"Debug Mode: {debug}") + + try: + if debug: + app.run(debug=True, host=host, port=port) + else: + try: + from waitress import serve + print("Running in production mode using waitress...") + serve(app, host=host, port=port) + except ImportError: + print("Waitress not found. Running with Flask's built-in server (NOT recommended for production).") + app.run(debug=False, host=host, port=port) + + except Exception as run_err: + print(f"!!! ERROR starting Flask server: {run_err}") + traceback.print_exc() diff --git a/backend/tests/test_app.py b/backend/tests/test_app.py index 9e5ff06..d006c39 100644 --- a/backend/tests/test_app.py +++ b/backend/tests/test_app.py @@ -34,4 +34,3 @@ def test_get_academic_years(client): data = response.get_json() assert isinstance(data, (dict)) -# Add more tests for other endpoints as needed diff --git a/package-lock.json b/package-lock.json index 632ad80..7c13a1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "@react-oauth/google": "^0.12.1", + "@stripe/stripe-js": "^7.2.0", "dotenv": "^16.5.0", "jwt-decode": "^4.0.0", "react": "^19.0.0", @@ -21,10 +22,13 @@ "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react-swc": "^3.8.0", + "autoprefixer": "^10.4.21", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4", "vite": "^6.3.2" } }, @@ -1071,6 +1075,15 @@ "win32" ] }, + "node_modules/@stripe/stripe-js": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.2.0.tgz", + "integrity": "sha512-BXlt6BsFE599yOATuz78FiW9z4SyipCH3j1SDyKWe/3OUBdhcOr/BWnBi1xrtcC2kfqrTJjgvfH2PTfMPRmbTw==", + "license": "MIT", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swc/core": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.21.tgz", @@ -1666,6 +1679,44 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1684,6 +1735,39 @@ "concat-map": "0.0.1" } }, + "node_modules/browserslist": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", + "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1694,6 +1778,27 @@ "node": ">=6" } }, + "node_modules/caniuse-lite": { + "version": "1.0.30001715", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz", + "integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -1908,6 +2013,13 @@ "url": "https://dotenvx.com" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.143", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.143.tgz", + "integrity": "sha512-QqklJMOFBMqe46k8iIOwA9l2hz57V2OKMmP5eSWcUvwx+mASAsbU+wkF1pHjn9ZVSBPrsYWr4/W/95y5SwYg2g==", + "dev": true, + "license": "ISC" + }, "node_modules/esbuild": { "version": "0.25.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", @@ -1949,6 +2061,16 @@ "@esbuild/win32-x64": "0.25.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -2227,6 +2349,20 @@ "dev": true, "license": "ISC" }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2481,6 +2617,23 @@ "dev": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2613,6 +2766,13 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2819,6 +2979,13 @@ "node": ">=8" } }, + "node_modules/tailwindcss": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz", + "integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", @@ -2849,6 +3016,37 @@ "node": ">= 0.8.0" } }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 71d8da3..dca7b92 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@react-oauth/google": "^0.12.1", + "@stripe/stripe-js": "^7.2.0", "dotenv": "^16.5.0", "jwt-decode": "^4.0.0", "react": "^19.0.0", @@ -23,10 +24,13 @@ "@types/react": "^19.0.10", "@types/react-dom": "^19.0.4", "@vitejs/plugin-react-swc": "^3.8.0", + "autoprefixer": "^10.4.21", "eslint": "^9.22.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "postcss": "^8.5.3", + "tailwindcss": "^4.1.4", "vite": "^6.3.2" } } diff --git a/requirements.txt b/requirements.txt index 367bb48..f7217dc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ PyMuPDF openai dotenv google-generativeai +stripe \ No newline at end of file diff --git a/src/App.css b/src/App.css index 6e2e23c..1da4689 100644 --- a/src/App.css +++ b/src/App.css @@ -1,129 +1,169 @@ -/* Reset and Base Styles */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + + body { - font-family: Arial, Helvetica, sans-serif; /* Common sans-serif font */ + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; margin: 0; padding: 0; - background-color: #f0f0f0; /* Light gray background like assist.org */ - color: #333; /* Standard dark text color */ + background-size: cover; + background-position: center; + background-attachment: fixed; + color: #333; line-height: 1.6; + font-size: 16px; } -/* Container for centering content */ -#root > div { /* Target the main div rendered by React */ - margin: 20px auto; /* Center the container */ - padding: 20px; - background-color: #fff; /* White background for content area */ - border: 1px solid #ccc; /* Subtle border */ - box-shadow: 0 2px 4px rgba(0,0,0,0.1); /* Slight shadow */ +#root > div { + margin: 40px auto; + padding: 30px 40px; + background-color: rgba(255, 255, 255, 0.95); + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 6px 18px rgba(0,0,0,0.12); + animation: fadeIn 0.5s ease-out forwards; } h1, h2, h3 { - color: #003366; /* Dark blue for headings */ + color: #1a2b4d; margin-top: 0; + margin-bottom: 1em; + font-weight: 600; +} + +h1 { + font-size: 2.2em; + text-align: center; + margin-bottom: 1.5em; } -/* Form Styling */ .form-group { - margin-bottom: 1rem; - position: relative; /* Needed for absolute positioning of dropdown */ + margin-bottom: 1.5rem; + position: relative; + transition: all 0.3s ease-in-out; } label { display: block; - margin-bottom: 0.5rem; - font-weight: bold; + margin-bottom: 0.6rem; + font-weight: 500; color: #555; + font-size: 0.95em; } input[type="text"], -select { /* Style both text inputs and selects similarly */ - padding: 8px 12px; - width: 100%; /* Full width within the container */ - border: 1px solid #ccc; - border-radius: 4px; - box-sizing: border-box; /* Include padding and border in width */ +select { + padding: 12px 15px; + width: 100%; + border: 1px solid #d1d5db; + border-radius: 6px; + box-sizing: border-box; font-size: 1rem; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +input[type="text"]:focus, +select:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); } input[type="text"]:disabled { - background-color: #e9ecef; /* Gray out disabled inputs */ + background-color: #f3f4f6; cursor: not-allowed; + color: #9ca3af; } -/* Button Styling */ button { - background-color: #005ea2; /* Assist.org primary blue */ + background-image: linear-gradient(to right, #3b82f6, #2563eb); color: white; border: none; - padding: 10px 20px; - border-radius: 4px; + padding: 12px 25px; + border-radius: 6px; cursor: pointer; - font-size: 1rem; - transition: background-color 0.2s ease; - display: inline-block; /* Allow setting width if needed, but default to content size */ - width: auto; /* Override previous 100% width */ - margin-bottom: 0; /* Remove default margin */ + font-size: 1.05rem; + font-weight: 500; + transition: all 0.2s ease; + display: inline-block; + width: auto; + margin-bottom: 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); } button:hover { - background-color: #003366; /* Darker blue on hover */ + background-image: linear-gradient(to right, #2563eb, #1d4ed8); + box-shadow: 0 4px 8px rgba(0,0,0,0.15); + transform: translateY(-2px); +} + +button:active { + transform: translateY(0); + box-shadow: 0 1px 2px rgba(0,0,0,0.1); } button:disabled { - background-color: #a0a0a0; /* Gray out disabled button */ + background-image: none; + background-color: #9ca3af; cursor: not-allowed; + box-shadow: none; + transform: none; } -/* Result Area */ .result { margin-top: 25px; - padding: 15px; + padding: 20px; border: 1px solid #ddd; - background-color: #f8f8f8; - border-radius: 4px; + background-color: #f8f9fa; + border-radius: 6px; + animation: fadeIn 0.5s ease-out forwards; } .result h3 { margin-top: 0; - color: #555; + color: #495057; } -/* Dropdown Styling */ .dropdown { position: absolute; background-color: white; - border: 1px solid #ccc; - border-top: none; /* Attach visually to input */ + border: 1px solid #d1d5db; + border-top: none; max-height: 250px; overflow-y: auto; - width: 100%; /* Match input width */ + width: 100%; box-sizing: border-box; z-index: 1000; - box-shadow: 0 4px 8px rgba(0,0,0,0.1); - border-radius: 0 0 4px 4px; /* Rounded bottom corners */ + box-shadow: 0 6px 12px rgba(0,0,0,0.1); + border-radius: 0 0 6px 6px; + margin-top: -1px; } .dropdown-item { - padding: 8px 12px; + padding: 10px 15px; cursor: pointer; font-size: 0.95rem; - border-bottom: 1px solid #eee; /* Separator lines */ + border-bottom: 1px solid #f3f4f6; + transition: background-color 0.15s ease, color 0.15s ease; } .dropdown-item:last-child { border-bottom: none; } .dropdown-item:hover { - background-color: #e9f5ff; /* Light blue hover */ - color: #005ea2; + background-color: #eff6ff; + color: #1d4ed8; } -/* Link Styling (e.g., Back to Form in PdfViewer) */ a { - color: #005ea2; + color: #2563eb; text-decoration: none; + transition: color 0.2s ease; } a:hover { + color: #1d4ed8; text-decoration: underline; } \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 2c05e64..80aa82d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,54 +1,149 @@ -import React, { useState } from 'react'; -import { Routes, Route, Link, useNavigate } from 'react-router-dom'; +import React, { useState, useEffect, useCallback } from 'react'; +import { Routes, Route, useNavigate } from 'react-router-dom'; import { GoogleLogin, googleLogout } from '@react-oauth/google'; import { jwtDecode } from "jwt-decode"; import CollegeTransferForm from './components/CollegeTransferForm'; import AgreementViewerPage from './components/AgreementViewerPage'; import CourseMap from './components/CourseMap'; import './App.css'; +import { fetchData } from './services/api'; + +import { loadStripe } from '@stripe/stripe-js'; + +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY); + + +export const USER_STORAGE_KEY = 'collegeTransferUser'; + +const PaymentSuccess = () => { + return ( +
+

Payment Successful!

+

Your subscription is now active. Thank you!

+ Go to Home +
+ ); +}; + +const PaymentCancel = () => { + return ( +
+

Payment Cancelled

+

Your payment process was cancelled. You can try again anytime.

+ Go to Home +
+ ); +}; -// Define a key for localStorage -const USER_STORAGE_KEY = 'collegeTransferUser'; function App() { - // Initialize user state from localStorage on initial load const [user, setUser] = useState(() => { try { const storedUser = localStorage.getItem(USER_STORAGE_KEY); if (storedUser) { const parsedUser = JSON.parse(storedUser); - // *** Check Token Expiration *** if (parsedUser.idToken) { const decoded = jwtDecode(parsedUser.idToken); - const isExpired = decoded.exp * 1000 < Date.now(); // Convert exp (seconds) to milliseconds + const isExpired = decoded.exp * 1000 < Date.now(); if (isExpired) { console.log("Stored token expired, clearing storage."); localStorage.removeItem(USER_STORAGE_KEY); - return null; // Treat as logged out + return null; } } else { - // Handle case where token might be missing in stored data console.warn("Stored user data missing idToken, clearing storage."); localStorage.removeItem(USER_STORAGE_KEY); return null; } - // *** End Check *** console.log("Loaded valid user from localStorage"); return parsedUser; } } catch (error) { console.error("Failed to load or validate user from localStorage:", error); - localStorage.removeItem(USER_STORAGE_KEY); // Clear corrupted/invalid data + localStorage.removeItem(USER_STORAGE_KEY); } - return null; // Default to null + return null; }); + const [userTier, setUserTier] = useState('free'); + const [isLoadingTier, setIsLoadingTier] = useState(false); + const navigate = useNavigate(); - // Function to handle successful login + const handleLogout = useCallback(() => { + googleLogout(); + setUser(null); + try { + localStorage.removeItem(USER_STORAGE_KEY); + console.log("Removed user from localStorage"); + } catch (storageError) { + console.error("Failed to remove user from localStorage:", storageError); + } + console.log("User logged out."); + navigate('/'); + setUserTier('free'); + }, [navigate]); + + useEffect(() => { + const handleAuthExpired = () => { + if (localStorage.getItem(USER_STORAGE_KEY)) { + console.log("Auth expired event received. Logging out."); + alert("Your session has expired. Please sign in again."); + handleLogout(); + } + }; + + window.addEventListener('auth-expired', handleAuthExpired); + + return () => { + window.removeEventListener('auth-expired', handleAuthExpired); + }; + }, [handleLogout]); + + + useEffect(() => { + const fetchUserStatus = () => { + if (user && user.idToken) { + setIsLoadingTier(true); + fetchData('/user-status', { + headers: { 'Authorization': `Bearer ${user.idToken}` } + }) + .then(data => { + if (data && data.tier) { + setUserTier(data.tier); + console.log("User tier updated:", data.tier); + } else { + console.warn("Could not fetch user tier:", data?.error); + setUserTier('free'); + } + }) + .catch(err => { + console.error("Error fetching user status:", err); + setUserTier('free'); + }) + .finally(() => { + setIsLoadingTier(false); + }); + } else { + setUserTier('free'); + setIsLoadingTier(false); + } + }; + + fetchUserStatus(); + + window.addEventListener('focus', fetchUserStatus); + + return () => { + window.removeEventListener('focus', fetchUserStatus); + }; + + }, [user?.idToken]); + + const handleLoginSuccess = (credentialResponse) => { console.log("Google Login Success:", credentialResponse); try { @@ -60,9 +155,8 @@ function App() { name: decoded.name, email: decoded.email, }; - setUser(newUser); // Update React state + setUser(newUser); - // Save user data to localStorage try { localStorage.setItem(USER_STORAGE_KEY, JSON.stringify(newUser)); console.log("Saved user to localStorage"); @@ -73,46 +167,98 @@ function App() { } catch (error) { console.error("Error decoding JWT:", error); setUser(null); - localStorage.removeItem(USER_STORAGE_KEY); // Clear storage on error + localStorage.removeItem(USER_STORAGE_KEY); } }; const handleLoginError = () => { console.error("Google Login Failed"); setUser(null); - localStorage.removeItem(USER_STORAGE_KEY); // Clear storage on login error + localStorage.removeItem(USER_STORAGE_KEY); + setUserTier('free'); }; - // Function to handle logout - const handleLogout = () => { - googleLogout(); // Clear Google session - setUser(null); // Clear React state + const handleManualLogoutClick = useCallback(() => { + handleLogout(); + }, [handleLogout]); - // Remove user data from localStorage - try { - localStorage.removeItem(USER_STORAGE_KEY); - console.log("Removed user from localStorage"); - } catch (storageError) { - console.error("Failed to remove user from localStorage:", storageError); - } - console.log("User logged out"); - navigate('/'); + const handleUpgradeClick = async () => { + if (!user || !user.idToken) { + alert("Please log in to upgrade."); + return; + } + + try { + console.log("Requesting checkout session..."); + const response = await fetchData('create-checkout-session', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${user.idToken}`, + 'Content-Type': 'application/json' + }, + }); + + if (response && response.sessionId) { + console.log("Received session ID:", response.sessionId); + const stripe = await stripePromise; + const { error } = await stripe.redirectToCheckout({ + sessionId: response.sessionId, + }); + if (error) { + console.error("Stripe redirect failed:", error); + alert(`Payment redirect failed: ${error.message}`); + } + } else { + throw new Error(response?.error || "Failed to get checkout session ID."); + } + } catch (error) { + console.error("Upgrade failed:", error); + alert(`Could not initiate payment: ${error.message}`); + } }; + return ( <> - {/* Navigation/Header remains the same */} - {/* Routes remain the same */} } /> } + element={} /> :

Please log in to view the course map.

} + element={} /> + } /> + } />
); } -export default App; +export default App; \ No newline at end of file diff --git a/src/components/AgreementViewerPage.jsx b/src/components/AgreementViewerPage.jsx index 77bb0fa..0ea32fd 100644 --- a/src/components/AgreementViewerPage.jsx +++ b/src/components/AgreementViewerPage.jsx @@ -1,402 +1,188 @@ -import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { useParams, Link } from 'react-router-dom'; -import { fetchData } from '../services/api'; +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; import PdfViewer from './PdfViewer'; import ChatInterface from './ChatInterface'; +import MajorsList from './AgreementViewerPage/MajorsList'; +import AgreementTabs from './AgreementViewerPage/AgreementTabs'; +import UsageStatusDisplay from './AgreementViewerPage/UsageStatusDisplay'; +import { useResizeHandler } from '../hooks/useResizeHandler'; +import { useUsageStatus } from '../hooks/useUsageStatus'; +import { useAgreementData } from '../hooks/useAgreementData'; import '../App.css'; -// Define minColWidth and dividerWidth as constants outside the component -const minColWidth = 150; -const dividerWidth = 1; -const fixedMajorsWidth = 300; // Revert to fixed width for majors +const MIN_COL_WIDTH = 150; +const FIXED_MAJORS_WIDTH = 300; -function AgreementViewerPage() { - const { sendingId, receivingId, yearId } = useParams(); +function AgreementViewerPage({ user, userTier }) { + const { sendingId: initialSendingId, receivingId, yearId } = useParams(); + const location = useLocation(); - // --- State for resizing --- - // Only need state for the chat column width now - const [chatColumnWidth, setChatColumnWidth] = useState(400); - const isResizingRef = useRef(false); - // Only need one divider ref now - const dividerRef = useRef(null); // Ref for the divider between Chat and PDF - const containerRef = useRef(null); // Ref for the main container + const allSelectedSendingInstitutions = useMemo(() => { + return location.state?.allSelectedSendingInstitutions || [{ id: initialSendingId, name: 'Unknown Sending Institution' }]; + }, [location.state?.allSelectedSendingInstitutions, initialSendingId]); + + const memoizedAllSendingInstitutionIds = useMemo(() => { + return allSelectedSendingInstitutions.map(inst => inst.id); + }, [allSelectedSendingInstitutions]); - // --- State for Majors Column Visibility --- const [isMajorsVisible, setIsMajorsVisible] = useState(true); - // --- Ref to hold the latest visibility state for the event listener --- const isMajorsVisibleRef = useRef(isMajorsVisible); + useEffect(() => { isMajorsVisibleRef.current = isMajorsVisible; }, [isMajorsVisible]); + + const { + chatColumnWidth, + setChatColumnWidth, + dividerRef, + containerRef, + handleMouseDown, + } = useResizeHandler(400, MIN_COL_WIDTH, FIXED_MAJORS_WIDTH, isMajorsVisibleRef); + + const { usageStatus, countdown } = useUsageStatus(user, userTier); + + const { + selectedCategory, majors, isLoadingMajors, error, pdfError, + selectedMajorKey, selectedMajorName, isLoadingPdf, majorSearchTerm, + hasMajorsAvailable, hasDepartmentsAvailable, isLoadingAvailability, + agreementData, activeTabIndex, imagesForActivePdf, + currentPdfFilename, filteredMajors, + allAgreementsImageFilenames, + handleMajorSelect, handleCategoryChange, handleTabClick, setMajorSearchTerm, + } = useAgreementData(initialSendingId, receivingId, yearId, user, allSelectedSendingInstitutions); - // --- Effect to update the ref whenever the state changes --- - useEffect(() => { - isMajorsVisibleRef.current = isMajorsVisible; - }, [isMajorsVisible]); - - // --- Existing State --- - const [majors, setMajors] = useState({}); - const [isLoadingMajors, setIsLoadingMajors] = useState(true); - const [error, setError] = useState(null); - const [pdfError, setPdfError] = useState(null); - const [selectedMajorKey, setSelectedMajorKey] = useState(null); - const [selectedMajorName, setSelectedMajorName] = useState(''); - const [selectedPdfFilename, setSelectedPdfFilename] = useState(null); - const [imageFilenames, setImageFilenames] = useState([]); - const [isLoadingPdf, setIsLoadingPdf] = useState(false); - const [majorSearchTerm, setMajorSearchTerm] = useState(''); - - // --- Effect for Fetching Majors (with Caching) --- - useEffect(() => { - if (!sendingId || !receivingId || !yearId) { - setError("Required institution or year information is missing in URL."); - setIsLoadingMajors(false); - return; - } + const toggleMajorsVisibility = () => { + const gapWidth = 16; + const majorsColumnTotalWidth = FIXED_MAJORS_WIDTH + gapWidth; - // Generate a unique cache key for this combination - const cacheKey = `majors-${sendingId}-${receivingId}-${yearId}`; - let cachedMajors = null; + setIsMajorsVisible(prevVisible => { + const nextVisible = !prevVisible; - // 1. Try loading from localStorage - try { - const cachedData = localStorage.getItem(cacheKey); - if (cachedData) { - cachedMajors = JSON.parse(cachedData); - console.log("Loaded majors from cache:", cacheKey); - setMajors(cachedMajors); - setIsLoadingMajors(false); - setError(null); - return; // Exit early if loaded from cache - } - } catch (e) { - console.error("Failed to read or parse majors cache:", e); - localStorage.removeItem(cacheKey); // Clear potentially corrupted cache entry - } + if (containerRef.current) { + const containerWidth = containerRef.current.getBoundingClientRect().width; + const pdfMinWidth = MIN_COL_WIDTH; + const dividerWidth = 1; - // 2. If not cached or cache failed, fetch from API - console.log("Fetching majors from API:", cacheKey); - setIsLoadingMajors(true); - setError(null); - fetchData(`majors?sendingInstitutionId=${sendingId}&receivingInstitutionId=${receivingId}&academicYearId=${yearId}&categoryCode=major`) - .then(data => { - if (data && Object.keys(data).length === 0) { // Check if data is not null/undefined before checking keys - setError("No majors found for the selected combination."); - setMajors({}); - } else if (data) { // Check if data is not null/undefined - setMajors(data); - try { - localStorage.setItem(cacheKey, JSON.stringify(data)); - console.log("Saved majors to cache:", cacheKey); - } catch (e) { - console.error("Failed to save majors to cache:", e); - } + if (!nextVisible) { + const availableWidthForChatAndPdf = containerWidth - pdfMinWidth - dividerWidth - gapWidth; + const targetChatWidth = chatColumnWidth + majorsColumnTotalWidth; + const newChatWidth = Math.min(targetChatWidth, availableWidthForChatAndPdf); + setChatColumnWidth(Math.max(newChatWidth, MIN_COL_WIDTH)); } else { - // Handle cases where fetchData might return null or undefined unexpectedly - setError("Received unexpected empty response when fetching majors."); - setMajors({}); + const targetChatWidth = chatColumnWidth - majorsColumnTotalWidth; + const newChatWidth = Math.max(targetChatWidth, MIN_COL_WIDTH); + setChatColumnWidth(newChatWidth); } - }) - .catch(err => { - console.error("Error fetching majors:", err); - setError(`Failed to load majors: ${err.message}`); - setMajors({}); - }) - .finally(() => { - setIsLoadingMajors(false); - }); - - }, [sendingId, receivingId, yearId]); // Re-run effect if IDs change - - // Fetch PDF filename AND image filenames when major is selected - const handleMajorSelect = async (majorKey, majorName) => { - if (!majorKey || isLoadingPdf) return; - - setSelectedMajorKey(majorKey); - setSelectedMajorName(majorName); // Store name - setSelectedPdfFilename(null); // Clear previous PDF filename - setImageFilenames([]); // Clear previous images - setIsLoadingPdf(true); - setError(null); // Clear general errors - setPdfError(null); // Clear specific PDF errors - - try { - // 1. Get PDF Filename - const agreementData = await fetchData(`articulation-agreement?key=${majorKey}`); - if (agreementData && agreementData.pdf_filename) { - const pdfFilename = agreementData.pdf_filename; - setSelectedPdfFilename(pdfFilename); // Set filename for context - - // --- Image Caching Logic --- - const imageCacheKey = `pdf-images-${pdfFilename}`; - let fetchedFromCache = false; - - // 2a. Try loading images from localStorage - try { - const cachedImageData = localStorage.getItem(imageCacheKey); - if (cachedImageData) { - const parsedImageData = JSON.parse(cachedImageData); - if (parsedImageData && parsedImageData.image_filenames) { - console.log("Loaded images from cache:", imageCacheKey); - setImageFilenames(parsedImageData.image_filenames); - fetchedFromCache = true; // Mark as fetched from cache - } else { - console.warn("Cached image data invalid, removing:", imageCacheKey); - localStorage.removeItem(imageCacheKey); - } - } - } catch (e) { - console.error("Failed to read or parse images cache:", e); - localStorage.removeItem(imageCacheKey); // Clear potentially corrupted cache entry - } - // --- End Image Caching Logic --- - - // 2b. Fetch images from API if not loaded from cache - if (!fetchedFromCache) { - console.log("Fetching images from API:", imageCacheKey); - const imageData = await fetchData(`pdf-images/${pdfFilename}`); - if (imageData && imageData.image_filenames) { - setImageFilenames(imageData.image_filenames); - // 3. Save successful image fetch to localStorage - try { - localStorage.setItem(imageCacheKey, JSON.stringify(imageData)); - console.log("Saved images to cache:", imageCacheKey); - } catch (e) { - console.error("Failed to save images to cache:", e); - } - } else { - // Handle case where API returns error or no filenames - throw new Error(imageData?.error || 'Failed to load image list for PDF'); - } - } - } else if (agreementData && agreementData.error) { - throw new Error(`Agreement Error: ${agreementData.error}`); - } else { - throw new Error('Received unexpected data or no PDF filename when fetching agreement.'); } - } catch (err) { - console.error("Error fetching agreement or images:", err); - setPdfError(err.message); // Set specific PDF error - setSelectedPdfFilename(null); // Clear filename on error - setImageFilenames([]); // Clear images on error - } finally { - setIsLoadingPdf(false); // Done loading PDF info + images - } + return nextVisible; + }); }; - // Filter majors based on search term - const filteredMajors = useMemo(() => { - const lowerCaseSearchTerm = majorSearchTerm.toLowerCase(); - // Ensure majors is an object before trying to get entries - if (typeof majors !== 'object' || majors === null) { - return []; - } - return Object.entries(majors).filter(([name]) => - name.toLowerCase().includes(lowerCaseSearchTerm) - ); - }, [majors, majorSearchTerm]); - - // --- Resizing Logic (Simplified for one divider) --- - const handleMouseDown = useCallback((e) => { - e.preventDefault(); - isResizingRef.current = true; - // Add the *same* memoized handleMouseMove function as the listener - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - document.body.style.cursor = 'col-resize'; - document.body.style.userSelect = 'none'; - }, []); // handleMouseMove is stable now, so no need to list it if it doesn't change - - const handleMouseMove = useCallback((e) => { // Keep useCallback, but dependencies change - if (!isResizingRef.current || !containerRef.current) { - return; - } - - const containerRect = containerRef.current.getBoundingClientRect(); - const mouseX = e.clientX; - const containerLeft = containerRect.left; - const totalWidth = containerRect.width; - const gapWidth = 16; // Assumed 1em = 16px - - // --- Read the *current* visibility from the ref --- - const currentVisibility = isMajorsVisibleRef.current; - - // Calculate the starting position of the chat column - const majorsEffectiveWidth = currentVisibility ? fixedMajorsWidth : 0; - const gap1EffectiveWidth = currentVisibility ? gapWidth : 0; // Gap between majors and chat - const chatStartOffset = majorsEffectiveWidth + gap1EffectiveWidth; - - // Calculate desired chat width based on mouse position relative to chat start - let newChatWidth = mouseX - containerLeft - chatStartOffset; - - // Constraints: ensure chat and PDF columns have minimum width - const maxChatWidth = totalWidth - chatStartOffset - minColWidth - gapWidth - dividerWidth; - newChatWidth = Math.max(minColWidth, Math.min(newChatWidth, maxChatWidth)); - - setChatColumnWidth(newChatWidth); + const currentChatFlexBasis = `${chatColumnWidth}px`; + const userName = user?.name || user?.email || "You"; + const mainContentHeight = `calc(90vh - 53px)`; - // --- Remove isMajorsVisible from dependencies, rely on the ref --- - }, []); // Empty dependency array is okay now because we use the ref + const currentSendingId = useMemo(() => allSelectedSendingInstitutions[0]?.id, [allSelectedSendingInstitutions]); - const handleMouseUp = useCallback(() => { - if (isResizingRef.current) { - isResizingRef.current = false; - // Remove the *same* handleMouseMove function instance - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - document.body.style.cursor = ''; - document.body.style.userSelect = ''; - } - // Ensure handleMouseMove is included if it's used inside, though it's stable now - }, [handleMouseMove]); + const memoizedImageFilenamesForChat = useMemo(() => { + return allAgreementsImageFilenames || []; + }, [allAgreementsImageFilenames]); - // Cleanup listeners useEffect(() => { - // Define cleanup using the memoized handleMouseMove - const currentHandleMouseMove = handleMouseMove; - const currentHandleMouseUp = handleMouseUp; - return () => { - window.removeEventListener('mousemove', currentHandleMouseMove); - window.removeEventListener('mouseup', currentHandleMouseUp); - }; - }, [handleMouseMove, handleMouseUp]); // Depend on the memoized functions - - // --- Toggle Majors Visibility --- - const toggleMajorsVisibility = () => { - const gapWidth = 16; // Ensure this matches the gap used in styles/calculations + console.log("AgreementViewerPage: memoizedImageFilenamesForChat updated:", memoizedImageFilenamesForChat); + }, [memoizedImageFilenamesForChat]); - // Use the functional update form to get the latest states - setIsMajorsVisible(prevVisible => { - const nextVisible = !prevVisible; - - // Adjust chat width based on the *change* in visibility - setChatColumnWidth(prevChatWidth => { - if (nextVisible === false) { // Majors are being hidden - // Increase chat width to absorb the space - return prevChatWidth + fixedMajorsWidth + gapWidth; - } else { // Majors are being shown - // Decrease chat width, ensuring it doesn't go below min width - const newWidth = prevChatWidth - fixedMajorsWidth - gapWidth; - return Math.max(minColWidth, newWidth); - } - }); - - return nextVisible; // Return the new visibility state - }); - }; - - - // Calculate effective widths for flex styling based on visibility - const currentMajorsFlexBasis = isMajorsVisible ? `${fixedMajorsWidth}px` : '0px'; - const currentChatFlexBasis = `${chatColumnWidth}px`; return ( <> - {/* Button Bar */} -
- - Back to Form -
- - {/* Main container using Flexbox */}
- - {/* Left Column (Majors List) - Conditionally Rendered, Fixed Width */} - {isMajorsVisible && ( -
- {/* Content of Majors Column */} -

Select Major

- setMajorSearchTerm(e.target.value)} - style={{ marginBottom: '0.5em', padding: '8px', border: '1px solid #ccc' }} - /> - {error &&
Error: {error}
} - {isLoadingMajors &&

Loading available majors...

} - {!isLoadingMajors && filteredMajors.length > 0 && ( -
- {filteredMajors.map(([name, key]) => ( -
handleMajorSelect(key, name)} - style={{ - padding: '8px 12px', - cursor: 'pointer', - borderBottom: '1px solid #eee', - backgroundColor: selectedMajorKey === key ? '#e0e0e0' : 'transparent', - fontWeight: selectedMajorKey === key ? 'bold' : 'normal' - }} - className="major-list-item" - > - {name} - {selectedMajorKey === key && isLoadingPdf && (Loading...)} -
- ))} -
- )} - {!isLoadingMajors && filteredMajors.length === 0 && Object.keys(majors).length > 0 && ( -

No majors match your search.

- )} - {!isLoadingMajors && Object.keys(majors).length === 0 && !error && ( -

No majors found.

- )} -
- )} - + position: 'relative', + }} + > + + + - {/* Middle Column (Chat Interface) - Dynamically Sized */} -
- {/* Render ChatInterface unconditionally */} +
- {/* --- Draggable Divider 2 (Now the only one) --- */}
- {/* --- End Divider --- */} - - {/* Right Column (PDF Viewer) - Takes Remaining Space */} -
+
+
-
); diff --git a/src/components/AgreementViewerPage/AgreementTabs.jsx b/src/components/AgreementViewerPage/AgreementTabs.jsx new file mode 100644 index 0000000..ec07031 --- /dev/null +++ b/src/components/AgreementViewerPage/AgreementTabs.jsx @@ -0,0 +1,58 @@ +import React from 'react'; + +function AgreementTabs({ + agreementData, + activeTabIndex, + handleTabClick, +}) { + const tabBaseStyle = { + padding: '10px 15px', + border: 'none', + cursor: 'pointer', + fontWeight: 'normal', + fontSize: '0.95em', + textAlign: 'center', + borderTopLeftRadius: '4px', + borderTopRightRadius: '4px', + marginRight: '2px', + }; + + const activeTabStyle = { + ...tabBaseStyle, + borderBottom: '3px solid #0056b3', + background: '#ffffff', + color: '#0056b3', + fontWeight: 'bold', + borderTop: 'none', + borderLeft: 'none', + borderRight: 'none', + }; + + const inactiveTabStyle = { + ...tabBaseStyle, + borderBottom: '3px solid transparent', + background: '#e9ecef', + color: '#495057', + borderTop: '1px solid #dee2e6', + borderLeft: '1px solid #dee2e6', + borderRight: '1px solid #dee2e6', + }; + + return ( +
+ {agreementData.map((agreement, index) => ( + + ))} +
+ ); +} + +export default AgreementTabs; diff --git a/src/components/AgreementViewerPage/MajorsList.jsx b/src/components/AgreementViewerPage/MajorsList.jsx new file mode 100644 index 0000000..a335242 --- /dev/null +++ b/src/components/AgreementViewerPage/MajorsList.jsx @@ -0,0 +1,112 @@ +import React from 'react'; + +function MajorsList({ + isMajorsVisible, + toggleMajorsVisibility, + selectedCategory, + handleCategoryChange, + majorSearchTerm, + setMajorSearchTerm, + filteredMajors, + handleMajorSelect, + isLoadingMajors, + error, + hasMajorsAvailable, + hasDepartmentsAvailable, + isLoadingAvailability, + selectedMajorKey, + isLoadingPdf, + majors +}) { + if (!isMajorsVisible) return null; + + return ( +
+ + +

+ Select {selectedCategory === 'major' ? 'Major' : 'Department'} +

+ +
+ {isLoadingAvailability ? (

Checking availability...

) : ( + <> + + + + )} +
+ + setMajorSearchTerm(e.target.value)} + style={{ marginBottom: '0.5em', padding: '8px', border: '1px solid #ccc' }} + /> + + {error &&
Error: {error}
} + {isLoadingMajors &&

Loading available {selectedCategory === 'major' ? 'majors' : 'departments'}...

} + + {!isLoadingMajors && filteredMajors.length > 0 && ( +
+ {filteredMajors.map(([name, key]) => ( +
handleMajorSelect(key, name)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + borderBottom: '1px solid #eee', + backgroundColor: selectedMajorKey === key ? '#e0e0e0' : 'transparent', + fontWeight: selectedMajorKey === key ? 'bold' : 'normal' + }} + className="major-list-item" + > + {name} {selectedMajorKey === key && isLoadingPdf && (Loading...)} +
+ ))} +
+ )} + + {!isLoadingMajors && filteredMajors.length === 0 && Object.keys(majors).length > 0 && ( +

No {selectedCategory === 'major' ? 'majors' : 'departments'} match your search.

+ )} + {!isLoadingMajors && Object.keys(majors).length === 0 && !error && ( +

No {selectedCategory === 'major' ? 'majors' : 'departments'} found.

+ )} + {!isLoadingMajors && !isLoadingAvailability && !hasMajorsAvailable && !hasDepartmentsAvailable && ( +

No majors or departments found for this combination.

+ )} +
+ ); +} + +export default MajorsList; diff --git a/src/components/AgreementViewerPage/UsageStatusDisplay.jsx b/src/components/AgreementViewerPage/UsageStatusDisplay.jsx new file mode 100644 index 0000000..e8f6ee8 --- /dev/null +++ b/src/components/AgreementViewerPage/UsageStatusDisplay.jsx @@ -0,0 +1,34 @@ +import React from 'react'; + +function UsageStatusDisplay({ user, usageStatus, countdown }) { + if (!user || (usageStatus.usageLimit === null && !usageStatus.error)) { + return null; + } + + return ( +
+ {usageStatus.error ? ( + {usageStatus.error} + ) : ( + <> + Tier: {usageStatus.tier || 'N/A'} | + Usage: {usageStatus.usageCount ?? 'N/A'} / {usageStatus.usageLimit ?? 'N/A'} | + {countdown || 'Calculating reset...'} + + )} +
+ ); +} + +export default UsageStatusDisplay; diff --git a/src/components/ChatInterface.jsx b/src/components/ChatInterface.jsx index 976faa3..eaa4bcb 100644 --- a/src/components/ChatInterface.jsx +++ b/src/components/ChatInterface.jsx @@ -1,172 +1,80 @@ -import React, { useState, useEffect, useRef } from 'react'; // Make sure useRef is imported if needed elsewhere -import { fetchData } from '../services/api'; - -function ChatInterface({ imageFilenames, selectedMajorName }) { - const [userInput, setUserInput] = useState(''); - const [messages, setMessages] = useState([]); // State to hold the conversation history - const [isLoading, setIsLoading] = useState(false); - const [chatError, setChatError] = useState(null); - const [messageNum, setMessageNum] = useState(0); // Track the number of messages sent/received pairs - - // Ref for scrolling - const messagesEndRef = useRef(null); - - // Scroll to bottom effect - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages]); // Trigger scroll whenever messages update - - // Clear chat and reset message count when agreement context changes - useEffect(() => { - setMessages([]); // Clear history - setUserInput(''); - setChatError(null); - setMessageNum(0); // Reset message counter for new agreement - console.log("Chat cleared due to new agreement context."); - }, [imageFilenames, selectedMajorName]); // Depend on the context identifiers - - const handleSend = async () => { - // Basic guard: Check for input, loading state. - // Allow sending even if imageFilenames is empty *after* the first message. - if (!userInput.trim() || isLoading) return; - // Guard specifically for the *first* message if images are required then. - if (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0)) { - console.warn("Attempted to send first message without image filenames."); - setChatError("Agreement images not loaded yet. Cannot start chat."); // Inform user - return; - } - - - const currentInput = userInput; // Capture input before clearing - const currentHistory = [...messages]; // Capture history *before* adding the new user message - - // Add user message to local state immediately for UI responsiveness - setMessages(prev => [...prev, { type: 'user', text: currentInput }]); - setUserInput(''); // Clear input field - setIsLoading(true); - setChatError(null); - - // --- Prepare data for Backend --- - // Map frontend message state to the format expected by the backend/OpenAI - const apiHistory = currentHistory.map(msg => ({ - role: msg.type === 'bot' ? 'assistant' : msg.type, // Map 'bot' to 'assistant' - content: msg.text // Assuming simple text content for history - // NOTE: This simple mapping assumes previous messages didn't contain complex content like images. - // If the assistant could previously return images, or if user could upload images mid-convo, - // the 'content' structure here and in the backend would need to be more robust. - })); - - const payload = { - new_message: currentInput, - history: apiHistory - }; - - // Add image_filenames only for the very first message (messageNum is 0) - const shouldSendImages = messageNum < 1; - if (shouldSendImages) { - payload.image_filenames = imageFilenames; - console.log("Sending image filenames with the first message."); - } - - // --- Backend Call --- - try { - console.log("Sending to /chat:", payload); // Log what's being sent - const response = await fetchData('chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload) // Send new message, history, and optional images - }); - - // Check if the response contains a reply - if (response && response.reply) { - // Add bot reply to local state - setMessages(prev => [...prev, { type: 'bot', text: response.reply }]); - setMessageNum(prev => prev + 1); // Increment message counter *after* successful round trip - } else { - // Handle cases where backend might return an error structure differently - throw new Error(response?.error || "No reply received or unexpected response format from chat API."); - } - } catch (err) { - console.error("Chat API error:", err); - setChatError(`Failed to get response: ${err.message}`); - // Optionally add a system message indicating the error, or revert the user message - // Reverting might be complex, adding a system error is simpler: - setMessages(prev => [...prev, { type: 'system', text: `Error: ${err.message}` }]); - } finally { - setIsLoading(false); - } - // --- End Backend Call --- - }; +import React from 'react'; +import ChatHeader from './ChatInterface/ChatHeader'; +import MessageList from './ChatInterface/MessageList'; +import MessageInput from './ChatInterface/MessageInput'; +import { useChat } from '../hooks/useChat'; + +function ChatInterface({ + imageFilenames, + selectedMajorName, + userName, + isMajorsVisible, + toggleMajorsVisibility, + sendingInstitutionId, + allSendingInstitutionIds, + receivingInstitutionId, + academicYearId, + user +}) { + const { + userInput, + setUserInput, + messages, + isLoading, + chatError, + handleSend + } = useChat( + imageFilenames, + selectedMajorName, + user, + sendingInstitutionId, + allSendingInstitutionIds, + receivingInstitutionId, + academicYearId + ); - // Disable input/button logic adjusted: - // Disable if loading. - // Disable if it's the first message (messageNum === 0) AND imageFilenames are missing/empty. - const isSendDisabled = isLoading || !userInput.trim() || (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0)); - const placeholderText = (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0)) - ? "Loading agreement context..." - : "Ask about the agreement..."; + const isInteractionDisabled = isLoading || !user || !imageFilenames || imageFilenames.length === 0; + const isSendDisabled = isInteractionDisabled || !userInput.trim(); + const placeholderText = !user + ? "Please sign in to use the chat feature." + : (!imageFilenames || imageFilenames.length === 0) + ? "Select a major/department to chat." + : isLoading + ? (messages.length === 0 ? "Analyzing agreement..." : "Thinking...") + : "Ask a follow-up question..."; return ( -
- {/* Optional Header */} -
- Chatting about: {selectedMajorName || "Selected Agreement"} -
- - {/* Message Display Area */} -
- {messages.length === 0 && !isLoading && ( -
- {placeholderText === "Loading agreement context..." ? placeholderText : "Ask a question about the selected transfer agreement."} -
- )} - {messages.map((msg, index) => ( -
- - {msg.text} - -
- ))} - {/* Add a ref to the end of the messages list for scrolling */} -
- {/* Loading/Error Indicators */} - {isLoading &&

Thinking...

} - {chatError && !isLoading &&

{chatError}

} -
- - {/* Input Area */} -
- setUserInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && !isSendDisabled && handleSend()} - placeholder={placeholderText} - style={{ flexGrow: 1, marginRight: '10px', padding: '10px', borderRadius: '20px', border: '1px solid #ccc' }} - disabled={isLoading || (messageNum < 1 && (!imageFilenames || imageFilenames.length === 0))} // Simplified disable logic - /> - -
+
+ + +
); } -export default ChatInterface; +export default ChatInterface; \ No newline at end of file diff --git a/src/components/ChatInterface/ChatHeader.jsx b/src/components/ChatInterface/ChatHeader.jsx new file mode 100644 index 0000000..948e74f --- /dev/null +++ b/src/components/ChatInterface/ChatHeader.jsx @@ -0,0 +1,34 @@ +import React from 'react'; + +function ChatHeader({ selectedMajorName, isMajorsVisible, toggleMajorsVisibility }) { + return ( +
+ Chatting about: {selectedMajorName || "Selected Agreement"} + + {!isMajorsVisible && ( + + )} +
+ ); +} + +export default ChatHeader; diff --git a/src/components/ChatInterface/ChatMessage.jsx b/src/components/ChatInterface/ChatMessage.jsx new file mode 100644 index 0000000..70c50c7 --- /dev/null +++ b/src/components/ChatInterface/ChatMessage.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { formatText } from '../../utils/formatText'; + +function ChatMessage({ msg, userName }) { + return ( +
+ + {msg.type === 'bot' && AI:} + {msg.type === 'user' && {userName || 'You'}:} + {formatText(msg.text)} + +
+ ); +} + +export default ChatMessage; diff --git a/src/components/ChatInterface/MessageInput.jsx b/src/components/ChatInterface/MessageInput.jsx new file mode 100644 index 0000000..da67772 --- /dev/null +++ b/src/components/ChatInterface/MessageInput.jsx @@ -0,0 +1,41 @@ +import React from 'react'; + +function MessageInput({ + userInput, + setUserInput, + handleSend, + placeholderText, + isInteractionDisabled, + isSendDisabled +}) { + return ( +
+ setUserInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && !isSendDisabled && handleSend()} + placeholder={placeholderText} + style={{ flexGrow: 1, marginRight: '10px', padding: '10px', borderRadius: '20px', border: '1px solid #ccc' }} + disabled={isInteractionDisabled} + /> + +
+ ); +} + +export default MessageInput; diff --git a/src/components/ChatInterface/MessageList.jsx b/src/components/ChatInterface/MessageList.jsx new file mode 100644 index 0000000..5076f13 --- /dev/null +++ b/src/components/ChatInterface/MessageList.jsx @@ -0,0 +1,36 @@ +import React, { useEffect, useRef } from 'react'; +import ChatMessage from './ChatMessage'; +import SignInPrompt from './SignInPrompt'; + +function MessageList({ messages, isLoading, chatError, user, userName, placeholderText }) { + const messagesEndRef = useRef(null); + + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + return ( +
+ {!user && } + + {user && messages.length === 0 && !isLoading && ( +
+ {placeholderText} +
+ )} + {user && messages.map((msg, index) => ( + + ))} +
+ {user && chatError && !isLoading &&

{chatError}

} +
+ ); +} + +export default MessageList; diff --git a/src/components/ChatInterface/SignInPrompt.jsx b/src/components/ChatInterface/SignInPrompt.jsx new file mode 100644 index 0000000..d55b82e --- /dev/null +++ b/src/components/ChatInterface/SignInPrompt.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +function SignInPrompt() { + return ( +
+ Please sign in to analyze agreements and chat with the AI counselor. +
+ ); +} + +export default SignInPrompt; diff --git a/src/components/CollegeTransferForm.jsx b/src/components/CollegeTransferForm.jsx index 68825a6..841fae5 100644 --- a/src/components/CollegeTransferForm.jsx +++ b/src/components/CollegeTransferForm.jsx @@ -1,168 +1,114 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { fetchData } from '../services/api'; import '../App.css'; +import { useReceivingInstitutions } from '../hooks/useReceivingInstitutions'; +import { useAcademicYears } from '../hooks/useAcademicYears'; const CollegeTransferForm = () => { const navigate = useNavigate(); - // --- State for fetched data --- - const [institutions, setInstitutions] = useState({}); - const [receivingInstitutions, setReceivingInstitutions] = useState({}); - const [academicYears, setAcademicYears] = useState({}); + const [institutions, setInstitutions] = useState({}); - // --- State for input values and selections --- const [sendingInputValue, setSendingInputValue] = useState(''); const [receivingInputValue, setReceivingInputValue] = useState(''); const [yearInputValue, setYearInputValue] = useState(''); - const [selectedSendingId, setSelectedSendingId] = useState(null); - const [selectedReceivingId, setSelectedReceivingId] = useState(null); + const [selectedSendingInstitutions, setSelectedSendingInstitutions] = useState([]); + const [selectedReceivingId, setSelectedReceivingId] = useState(null); const [selectedYearId, setSelectedYearId] = useState(null); - // --- State for dropdown visibility and filtered options --- const [showSendingDropdown, setShowSendingDropdown] = useState(false); const [showReceivingDropdown, setShowReceivingDropdown] = useState(false); const [showYearDropdown, setShowYearDropdown] = useState(false); - const [filteredInstitutions, setFilteredInstitutions] = useState([]); - const [filteredReceiving, setFilteredReceiving] = useState([]); + const [filteredSending, setFilteredSending] = useState([]); + const [filteredReceiving, setFilteredReceiving] = useState([]); const [filteredYears, setFilteredYears] = useState([]); - // --- State for loading and results --- - const [isLoading] = useState(false); // Keep for initial loads if needed + const [isLoading] = useState(false); const [error, setError] = useState(null); - // --- Helper Functions --- - useCallback(() => { - setSendingInputValue(''); - setReceivingInputValue(''); - setYearInputValue(''); - setSelectedSendingId(null); - setSelectedReceivingId(null); - setSelectedYearId(null); - setReceivingInstitutions({}); - setFilteredInstitutions([]); - setFilteredReceiving([]); - setFilteredYears([]); - setShowSendingDropdown(false); - setShowReceivingDropdown(false); - setShowYearDropdown(false); - setError(null); - }, []); + const institutionsCacheRef = useRef(null); + + const { availableReceivingInstitutions, isLoading: isLoadingReceiving, error: receivingError } = useReceivingInstitutions(selectedSendingInstitutions); + const { academicYears, isLoading: isLoadingYears, error: yearsError } = useAcademicYears(selectedSendingInstitutions, selectedReceivingId); + + const combinedError = receivingError || yearsError || error; + + // const resetForm = useCallback(() => { + // setSendingInputValue(''); + // setReceivingInputValue(''); + // setYearInputValue(''); + // setSelectedSendingInstitutions([]); + // setSelectedReceivingId(null); + // setSelectedYearId(null); + // setAvailableReceivingInstitutions({}); + // setFilteredSending([]); + // setFilteredReceiving([]); + // setFilteredYears([]); + // setShowSendingDropdown(false); + // setShowReceivingDropdown(false); + // setShowYearDropdown(false); + // setError(null); + // }, []); - // --- Effects for Initial Data Loading --- useEffect(() => { - const cacheInstitutionsKey = "instutions" - let cachedInstitutions = null + const cacheInstitutionsKey = "allInstitutions"; + let cachedInstitutions = null; + + if (institutionsCacheRef.current) { + console.log("Loaded institutions from in-memory cache."); + setInstitutions(institutionsCacheRef.current); + setError(null); + return; + } try { const cachedData = localStorage.getItem(cacheInstitutionsKey); if (cachedData) { cachedInstitutions = JSON.parse(cachedData); - console.log("Loaded institutions from cache:", cacheInstitutionsKey); + console.log("Loaded institutions from localStorage:", cacheInstitutionsKey); setInstitutions(cachedInstitutions); + institutionsCacheRef.current = cachedInstitutions; setError(null); return; } } catch (e) { - console.error("Error loading institutions from cache:", e); - localStorage.removeItem(cacheInstitutionsKey); // Clear cache on error + console.error("Error loading institutions from localStorage:", e); + localStorage.removeItem(cacheInstitutionsKey); } - - fetchData('institutions') + console.log("Cache miss for institutions. Fetching..."); + fetchData('/institutions') .then(data => { - setInstitutions(data) - try { - localStorage.setItem(cacheInstitutionsKey, JSON.stringify(data)); - console.log("Institutions cached successfully:", cacheInstitutionsKey); - } catch (e) { - console.error("Error caching institutions:", e); + if (data && Object.keys(data).length > 0) { + setInstitutions(data); + institutionsCacheRef.current = data; + try { + localStorage.setItem(cacheInstitutionsKey, JSON.stringify(data)); + console.log("Institutions cached successfully:", cacheInstitutionsKey); + } catch (e) { + console.error("Error caching institutions:", e); + } + } else { + setInstitutions({}); + setError("No institutions found from API."); } }) .catch(err => setError(`Failed to load institutions: ${err.message}`)); - - }, []); - - useEffect(() => { - - const cacheAcademicYearsKey = "academic-years" - let cachedAcademicYears = null - - try { - const cachedData = localStorage.getItem(cacheAcademicYearsKey); - if (cachedData) { - cachedAcademicYears = JSON.parse(cachedData); - console.log("Loaded academic years from cache:", cacheAcademicYearsKey); - setAcademicYears(cachedAcademicYears); - setError(null); - return; - } - } catch (e) { - console.error("Error loading academic years from cache:", e); - localStorage.removeItem(cacheAcademicYearsKey); // Clear cache on error - } - - fetchData('academic-years') - .then(data => { - setAcademicYears(data) - try { - localStorage.setItem(cacheAcademicYearsKey, JSON.stringify(data)); - console.log("Academic Years cached successfully:", cacheAcademicYearsKey); - } catch (e) { - console.error("Error caching academic years:", e); - } - }) - .catch(err => setError(`Failed to load academic years: ${err.message}`)); - }, []); - - // --- Effects for Dependent Data Loading --- - useEffect(() => { - setReceivingInputValue(''); - setSelectedReceivingId(null); - setReceivingInstitutions({}); - setFilteredReceiving([]); - - const cacheReceivingInstitutionsKey = `receiving-instutions-${selectedSendingId}` - let cachedReceivingInstitutions = null - - try { - const cachedData = localStorage.getItem(cacheReceivingInstitutionsKey); - if (cachedData) { - cachedReceivingInstitutions = JSON.parse(cachedData); - console.log("Loaded receiving institutions from cache:", cacheReceivingInstitutionsKey); - setReceivingInstitutions(cachedReceivingInstitutions); - setError(null); - return; - } - } catch (e) { - console.error("Error loading receiving institutions from cache:", e); - localStorage.removeItem(cacheReceivingInstitutionsKey); // Clear cache on error - } - - if (selectedSendingId) { - fetchData(`receiving-institutions?sendingInstitutionId=${selectedSendingId}`) - .then(data => { - setReceivingInstitutions(data) - try { - localStorage.setItem(cacheReceivingInstitutionsKey, JSON.stringify(data)); - console.log("Receiving institutions cached successfully:", cacheReceivingInstitutionsKey); - } catch (e) { - console.error("Error caching receiving institutions:", e); - } - }) - .catch(err => setError(`Failed to load receiving institutions: ${err.message}`)); - } - }, [selectedSendingId]); + }, []); - // --- Effects for Filtering Dropdowns --- + const filter = useCallback( - ((value, data, setFiltered, setShowDropdown) => { + ((value, data, setFiltered, setShowDropdown, excludeIds = []) => { const lowerCaseValue = value.toLowerCase(); const filtered = Object.entries(data) - .filter(([name]) => name.toLowerCase().includes(lowerCaseValue)) + .filter(([name, id]) => + !excludeIds.includes(id) && + name.toLowerCase().includes(lowerCaseValue) + ) .map(([name, id]) => ({ name, id })); setFiltered(filtered); setShowDropdown(true); @@ -171,40 +117,48 @@ const CollegeTransferForm = () => { ); useEffect(() => { + const alreadySelectedIds = selectedSendingInstitutions.map(inst => inst.id); if (sendingInputValue) { - filter(sendingInputValue, institutions, setFilteredInstitutions, setShowSendingDropdown); + filter(sendingInputValue, institutions, setFilteredSending, setShowSendingDropdown, alreadySelectedIds); } else { - // Keep dropdown open on focus, hide on blur or empty - if (!document.activeElement || document.activeElement.id !== 'searchInstitution') { - setShowSendingDropdown(false); - } - setFilteredInstitutions(Object.entries(institutions).map(([name, id]) => ({ name, id }))); // Show all on empty/focus + const allAvailable = Object.entries(institutions) + .filter(([, id]) => !alreadySelectedIds.includes(id)) + .map(([name, id]) => ({ name, id })); + setFilteredSending(allAvailable); + if (!document.activeElement || document.activeElement.id !== 'searchInstitution') { + setShowSendingDropdown(false); + } } - }, [sendingInputValue, institutions, filter]); + }, [sendingInputValue, institutions, selectedSendingInstitutions, filter]); useEffect(() => { - if (receivingInputValue && selectedSendingId) { - filter(receivingInputValue, receivingInstitutions, setFilteredReceiving, setShowReceivingDropdown); + const sourceData = availableReceivingInstitutions; + const excludeIds = []; + + if (receivingInputValue) { + filter(receivingInputValue, sourceData, setFilteredReceiving, setShowReceivingDropdown, excludeIds); } else { + const allAvailable = Object.entries(sourceData) + .map(([name, id]) => ({ name, id })); + setFilteredReceiving(allAvailable); if (!document.activeElement || document.activeElement.id !== 'receivingInstitution') { setShowReceivingDropdown(false); } - setFilteredReceiving(Object.entries(receivingInstitutions).map(([name, id]) => ({ name, id }))); // Show all on empty/focus } - }, [receivingInputValue, receivingInstitutions, selectedSendingId, filter]); + }, [receivingInputValue, availableReceivingInstitutions, filter]); useEffect(() => { - if (yearInputValue) { + const isYearInputEnabled = selectedSendingInstitutions.length > 0 && selectedReceivingId; + if (yearInputValue && isYearInputEnabled) { filter(yearInputValue, academicYears, setFilteredYears, setShowYearDropdown); } else { if (!document.activeElement || document.activeElement.id !== 'academicYears') { setShowYearDropdown(false); } - setFilteredYears(Object.entries(academicYears).map(([name, id]) => ({ name, id })).reverse()); // Show all on empty/focus + setFilteredYears(isYearInputEnabled ? Object.entries(academicYears).map(([name, id]) => ({ name, id })).reverse() : []); } - }, [yearInputValue, academicYears, filter]); + }, [yearInputValue, academicYears, filter, selectedSendingInstitutions, selectedReceivingId]); - // --- Event Handlers --- const handleInputChange = (e, setInputValue) => { setInputValue(e.target.value); setError(null); @@ -214,48 +168,60 @@ const CollegeTransferForm = () => { setError(null); switch (inputId) { case 'sending': - setSendingInputValue(item.name); - setSelectedSendingId(item.id); + setSelectedSendingInstitutions(prev => [...prev, item]); + setSendingInputValue(''); setShowSendingDropdown(false); - setFilteredInstitutions([]); // Clear filter on select + setFilteredSending([]); break; case 'receiving': setReceivingInputValue(item.name); setSelectedReceivingId(item.id); + setYearInputValue(''); + setSelectedYearId(null); setShowReceivingDropdown(false); - setFilteredReceiving([]); // Clear filter on select + setFilteredReceiving([]); break; case 'year': setYearInputValue(item.name); setSelectedYearId(item.id); setShowYearDropdown(false); - setFilteredYears([]); // Clear filter on select + setFilteredYears([]); break; - // REMOVED: case 'major' default: break; } }; - const handleViewMajors = () => { // Keep name or rename to handleViewAgreements - if (!selectedSendingId || !selectedReceivingId || !selectedYearId) { - setError("Please select sending institution, receiving institution, and academic year first."); + const handleRemoveSending = (idToRemove) => { + setSelectedSendingInstitutions(prev => prev.filter(inst => inst.id !== idToRemove)); + }; + + const handleViewMajors = () => { + if (selectedSendingInstitutions.length === 0) { + setError("Please select at least one sending institution."); + return; + } + if (!selectedReceivingId || !selectedYearId) { + setError("Please select receiving institution and academic year."); return; } setError(null); - // Navigate to the new combined agreement viewer page - navigate(`/agreement/${selectedSendingId}/${selectedReceivingId}/${selectedYearId}`); + const firstSendingId = selectedSendingInstitutions[0].id; + + navigate(`/agreement/${firstSendingId}/${selectedReceivingId}/${selectedYearId}`, { + state: { + allSelectedSendingInstitutions: selectedSendingInstitutions + } + }); }; - // --- Render Dropdown --- const renderDropdown = (items, show, inputId) => { - // Ensure items is an array before mapping if (!show || !Array.isArray(items) || items.length === 0) return null; return (
{items.map((item) => (
handleDropdownSelect(item, inputId)} > @@ -266,81 +232,121 @@ const CollegeTransferForm = () => { ); }; - // --- Component JSX --- return (

College Transfer AI

- {error &&
Error: {error}
} + {combinedError &&
Error: {combinedError}
} - {/* Sending Institution */}
- + + +
0 ? '0.5em' : '0', display: 'flex', flexWrap: 'wrap', gap: '0.5em' }}> + {selectedSendingInstitutions.map(inst => ( + + {inst.name} + + + ))} +
+ handleInputChange(e, setSendingInputValue)} onFocus={() => { - const allOptions = Object.entries(institutions).map(([name, id]) => ({ name, id })); - setFilteredInstitutions(allOptions); + const alreadySelectedIds = selectedSendingInstitutions.map(inst => inst.id); + const allAvailable = Object.entries(institutions) + .filter(([, id]) => !alreadySelectedIds.includes(id)) + .map(([name, id]) => ({ name, id })); + setFilteredSending(allAvailable); setShowSendingDropdown(true); }} - onBlur={() => setShowSendingDropdown(false)} + onBlur={() => setShowSendingDropdown(false)} autoComplete="off" /> - {renderDropdown(filteredInstitutions, showSendingDropdown, 'sending')} + {renderDropdown(filteredSending, showSendingDropdown, 'sending')}
- {/* Receiving Institution */}
- + handleInputChange(e, setReceivingInputValue)} onFocus={() => { - const allOptions = Object.entries(receivingInstitutions).map(([name, id]) => ({ name, id })); - setFilteredReceiving(allOptions); - setShowReceivingDropdown(true); + if (!isLoadingReceiving && selectedSendingInstitutions.length > 0) { + const sourceData = availableReceivingInstitutions; + const allAvailable = Object.entries(sourceData) + .map(([name, id]) => ({ name, id })); + setFilteredReceiving(allAvailable); + setShowReceivingDropdown(true); + } }} onBlur={() => setShowReceivingDropdown(false)} - disabled={!selectedSendingId} + disabled={isLoadingReceiving || selectedSendingInstitutions.length === 0 || (!isLoadingReceiving && Object.keys(availableReceivingInstitutions).length === 0)} autoComplete="off" /> {renderDropdown(filteredReceiving, showReceivingDropdown, 'receiving')}
- {/* Academic Year */}
handleInputChange(e, setYearInputValue)} onFocus={() => { - const allOptions = Object.entries(academicYears) - .map(([name, id]) => ({ name, id })) - .reverse(); - setFilteredYears(allOptions); - setShowYearDropdown(true); + if (!isLoadingYears && selectedSendingInstitutions.length > 0 && selectedReceivingId) { + const allOptions = Object.entries(academicYears) + .map(([name, id]) => ({ name, id })) + .sort((a, b) => b.name.localeCompare(a.name)); + setFilteredYears(allOptions); + setShowYearDropdown(true); + } }} onBlur={() => setShowYearDropdown(false)} + disabled={isLoadingYears || selectedSendingInstitutions.length === 0 || !selectedReceivingId || (!isLoadingYears && Object.keys(academicYears).length === 0)} autoComplete="off" /> - {renderDropdown(filteredYears, showYearDropdown, 'year')} + {(!isLoadingYears && selectedSendingInstitutions.length > 0 && selectedReceivingId) && + renderDropdown(filteredYears, showYearDropdown, 'year')}
- {/* MODIFIED: Button */}
); diff --git a/src/components/CourseMap.jsx b/src/components/CourseMap.jsx index d5710b8..36a35a6 100644 --- a/src/components/CourseMap.jsx +++ b/src/components/CourseMap.jsx @@ -1,22 +1,19 @@ -// filepath: c:\Users\notto\Desktop\Desktop\Projects\CollegeTransferAI\src\components\CourseMap.jsx -import React, { useState, useCallback, useRef, useEffect } from 'react'; +import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import ReactFlow, { Controls, Background, addEdge, MiniMap, BackgroundVariant, ReactFlowProvider, useReactFlow, useNodesState, useEdgesState, + Position } from 'reactflow'; import 'reactflow/dist/style.css'; import { fetchData } from '../services/api'; +import CustomCourseNode from './CustomCourseNode'; - -// --- Default/Initial Data (used for NEW maps) --- -const defaultNodes = []; // Start new maps empty +const defaultNodes = []; const defaultEdges = []; -// --- End Default Data --- -let idCounter = 0; // Reset counter, will be updated based on loaded nodes +let idCounter = 0; const getUniqueNodeId = () => `new_node_${idCounter++}`; -// --- Edit Input Component (remains the same) --- function EditInput({ element, value, onChange, onSave }) { const inputRef = useRef(null); useEffect(() => { @@ -35,11 +32,9 @@ function EditInput({ element, value, onChange, onSave }) {
); } -// --- End Edit Input Component --- -// --- Helper Functions for Cache --- const getCacheKey = (base, userId, mapId = null) => { - if (!userId) return null; // Cannot generate key without user ID + if (!userId) return null; return mapId ? `${base}-${userId}-${mapId}` : `${base}-${userId}`; }; @@ -53,7 +48,7 @@ const loadFromCache = (key) => { } } catch (e) { console.error(`Failed to read or parse cache for ${key}:`, e); - localStorage.removeItem(key); // Clear potentially corrupted cache + localStorage.removeItem(key); } return null; }; @@ -65,7 +60,6 @@ const saveToCache = (key, data) => { console.log(`Saved to cache: ${key}`); } catch (e) { console.error(`Failed to save to cache for ${key}:`, e); - // Handle potential storage limits if necessary } }; @@ -78,10 +72,9 @@ const removeFromCache = (key) => { console.error(`Failed to remove cache for ${key}:`, e); } }; -// --- End Helper Functions for Cache --- -function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. +function CourseMapFlow({ user }) { const reactFlowWrapper = useRef(null); const { screenToFlowPosition } = useReactFlow(); const [nodes, setNodes, onNodesChange] = useNodesState(defaultNodes); @@ -91,129 +84,116 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. const [isLoading, setIsLoading] = useState(true); const [saveStatus, setSaveStatus] = useState(''); - // --- State for Multiple Maps --- - const [mapList, setMapList] = useState([]); // List of { map_id, map_name, last_updated } - const [currentMapId, setCurrentMapId] = useState(null); // ID of the currently loaded map - const [currentMapName, setCurrentMapName] = useState('Untitled Map'); // Name of the current map + const [mapList, setMapList] = useState([]); + const [currentMapId, setCurrentMapId] = useState(null); + const [currentMapName, setCurrentMapName] = useState('Untitled Map'); const [isMapListLoading, setIsMapListLoading] = useState(true); - // --- End State for Multiple Maps --- - const userId = user?.id; // Get user ID for cache keys + const isLoggedIn = !!user; + + const userId = user?.id; - // --- Load Map List (with Cache) --- const loadMapList = useCallback(async (forceRefresh = false) => { const cacheKey = getCacheKey('courseMapList', userId); - if (!userId) { + if (!isLoggedIn) { setMapList([]); setIsMapListLoading(false); return []; } - // Try loading from cache first unless forcing refresh if (!forceRefresh) { const cachedList = loadFromCache(cacheKey); - if (cachedList && Array.isArray(cachedList)) { // Added Array.isArray check for cache + if (cachedList && Array.isArray(cachedList)) { setMapList(cachedList); setIsMapListLoading(false); return cachedList; } else if (cachedList) { console.warn("Cached map list was not an array, removing:", cachedList); - removeFromCache(cacheKey); // Remove invalid cache + removeFromCache(cacheKey); } } - // If not cached or forcing refresh, fetch from API setIsMapListLoading(true); try { console.log("Fetching map list from API..."); - const responseData = await fetchData('course-maps', { // Renamed 'list' to 'responseData' + const responseData = await fetchData('course-maps', { headers: { 'Authorization': `Bearer ${user.idToken}` } }); - console.log("Raw API response for map list:", responseData); // Log the raw response + console.log("Raw API response for map list:", responseData); - // --- Added Check --- - // Ensure responseData is an array before setting state const validList = Array.isArray(responseData) ? responseData : []; if (!Array.isArray(responseData)) { console.warn("API response for map list was not an array:", responseData); } - // --- End Check --- setMapList(validList); - saveToCache(cacheKey, validList); // Save fetched list to cache + saveToCache(cacheKey, validList); console.log("Map list fetched and cached:", validList); return validList; } catch (error) { console.error("Failed to load map list:", error); setSaveStatus(`Error loading map list: ${error.message}`); - setMapList([]); // Reset on error - removeFromCache(cacheKey); // Clear potentially stale cache on error + setMapList([]); + removeFromCache(cacheKey); return []; } finally { setIsMapListLoading(false); } - }, [userId, user?.idToken]); // Depend on userId and token + }, [userId, user?.idToken, isLoggedIn]); const handleNewMap = useCallback(async () => { - if (!userId || !user?.idToken) { - setSaveStatus("Please log in to create a map."); + if (!isLoggedIn) { + setSaveStatus("Please log in to create a new map."); return; } const mapName = prompt("Enter a name for the new map:", "Untitled Map"); - if (mapName === null) { // User cancelled prompt - setSaveStatus(''); // Clear saving status + if (mapName === null) { + setSaveStatus(''); return; } console.log("Initiating new map creation via API..."); - setIsLoading(true); // Show loading state for the map area + setIsLoading(true); setSaveStatus("Creating new map..."); try { - // Call the backend endpoint to create the empty map record - const newMapData = await fetchData('course-map', { // POST to the collection endpoint + const newMapData = await fetchData('course-map', { method: 'POST', headers: { 'Authorization': `Bearer ${user.idToken}`, - 'Content-Type': 'application/json' // Good practice + 'Content-Type': 'application/json' }, - // --- Send map_name AND empty nodes/edges --- body: JSON.stringify({ map_name: mapName, - nodes: [], // Always send empty nodes array for new map - edges: [] // Always send empty edges array for new map + nodes: [], + edges: [] }) - // --- End Change --- }); - // --- Response handling remains the same --- if (newMapData && newMapData.map_id) { console.log("New map record created:", newMapData); - // 1. Update Map List State & Cache const newMapEntry = { map_id: newMapData.map_id, - map_name: newMapData.map_name || mapName, // Use returned name or prompted name - last_updated: newMapData.last_updated || new Date().toISOString() // Use returned date or now + map_name: newMapData.map_name || mapName, + last_updated: newMapData.last_updated || new Date().toISOString() }; setMapList(prevList => { const newList = [newMapEntry, ...prevList]; newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); const listCacheKey = getCacheKey('courseMapList', userId); - saveToCache(listCacheKey, newList); // Update list cache + saveToCache(listCacheKey, newList); return newList; }); - // 2. Update Current Map State - setNodes(defaultNodes); // Reset nodes/edges for the new map + setNodes(defaultNodes); setEdges(defaultEdges); - setCurrentMapId(newMapData.map_id); // Set the new ID - setCurrentMapName(newMapEntry.map_name); // Set the name from newMapEntry - idCounter = 0; // Reset node counter + setCurrentMapId(newMapData.map_id); + setCurrentMapName(newMapEntry.map_name); + idCounter = 0; - // 3. Update Specific Map Cache (optional but good practice) const mapCacheKey = getCacheKey('courseMap', userId, newMapData.map_id); - saveToCache(mapCacheKey, { // Cache the initial empty state + saveToCache(mapCacheKey, { nodes: defaultNodes, edges: defaultEdges, map_id: newMapData.map_id, @@ -227,40 +207,51 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. } else { throw new Error("Failed to create map record: Invalid response from server."); } - // --- End Response handling --- } catch (error) { console.error("Failed to create new map:", error); setSaveStatus(`Error creating map: ${error.message}`); - // Don't reset state here, keep the user's current view } finally { - setIsLoading(false); // Hide loading state + setIsLoading(false); } - }, [userId, user?.idToken, setNodes, setEdges, setMapList]); // Added setMapList dependency + }, [userId, user?.idToken, setNodes, setEdges, setMapList, isLoggedIn]); - // --- Load Specific Map (with Cache) --- const loadSpecificMap = useCallback(async (mapId, forceRefresh = false) => { const cacheKey = getCacheKey('courseMap', userId, mapId); - if (!userId || !mapId) { // Handle new map case + if (!isLoggedIn) { setNodes(defaultNodes); setEdges(defaultEdges); setCurrentMapId(null); setCurrentMapName('Untitled Map'); idCounter = 0; setIsLoading(false); - setSaveStatus(''); // Clear any previous status + setSaveStatus(''); + return; + } + + if (!userId || !mapId) { + setNodes(defaultNodes); + setEdges(defaultEdges); + setCurrentMapId(null); + setCurrentMapName('Untitled Map'); + idCounter = 0; + setIsLoading(false); + setSaveStatus(''); return; } setIsLoading(true); setSaveStatus(''); - // Try loading from cache first unless forcing refresh if (!forceRefresh) { const cachedMap = loadFromCache(cacheKey); if (cachedMap && cachedMap.nodes && cachedMap.edges) { - setNodes(cachedMap.nodes); + const loadedNodes = cachedMap.nodes.map(node => ({ + ...node, + type: node.type || 'courseNode' + })); + setNodes(loadedNodes); setEdges(cachedMap.edges); setCurrentMapId(cachedMap.map_id); setCurrentMapName(cachedMap.map_name || 'Untitled Map'); @@ -269,11 +260,10 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. return match ? Math.max(maxId, parseInt(match[1], 10) + 1) : maxId; }, cachedMap.nodes.length); setIsLoading(false); - return; // Exit early if loaded from cache + return; } } - // If not cached or forcing refresh, fetch from API console.log(`Fetching map data for ID: ${mapId} from API...`); try { const data = await fetchData(`course-map/${mapId}`, { @@ -281,7 +271,11 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. }); if (data && data.nodes && data.edges) { console.log("Loaded map data from API:", data); - setNodes(data.nodes); + const loadedNodes = data.nodes.map(node => ({ + ...node, + type: node.type || 'courseNode' + })); + setNodes(loadedNodes); setEdges(data.edges); setCurrentMapId(data.map_id); setCurrentMapName(data.map_name || 'Untitled Map'); @@ -289,70 +283,62 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. const match = node.id.match(/^new_node_(\d+)$/); return match ? Math.max(maxId, parseInt(match[1], 10) + 1) : maxId; }, data.nodes.length); - saveToCache(cacheKey, data); // Save fetched map to cache + saveToCache(cacheKey, data); } else { console.warn(`Map ${mapId} not found or invalid data from API.`); setSaveStatus(`Error: Map ${mapId} not found.`); - removeFromCache(cacheKey); // Remove potentially invalid cache entry - handleNewMap(); // Reset to a new map state + removeFromCache(cacheKey); + handleNewMap(); } } catch (error) { console.error(`Failed to load course map ${mapId}:`, error); setSaveStatus(`Error loading map: ${error.message}`); - removeFromCache(cacheKey); // Remove potentially invalid cache entry - handleNewMap(); // Reset to a new map state on error + removeFromCache(cacheKey); + handleNewMap(); } finally { setIsLoading(false); } - }, [userId, user?.idToken, setNodes, setEdges, handleNewMap]); // Added handleNewMap dependency + }, [userId, user?.idToken, setNodes, setEdges, handleNewMap, isLoggedIn]); - // --- Initial Load Effect --- useEffect(() => { - setIsLoading(true); // Set loading true initially - loadMapList().then((list) => { - // After loading the list, decide which map to load - if (list && list.length > 0) { - // Load the most recently updated map by default - loadSpecificMap(list[0].map_id); - } else { - // No saved maps, start with a new one - loadSpecificMap(null); // This will reset to default empty state - } - }); - }, [loadMapList, loadSpecificMap]); // Depend on the loading functions + setIsLoading(true); + if (isLoggedIn) { + loadMapList().then((list) => { + if (list && list.length > 0) { + loadSpecificMap(list[0].map_id); + } else { + loadSpecificMap(null); + } + }); + } else { + loadSpecificMap(null); + } + }, [loadMapList, loadSpecificMap, isLoggedIn]); - // --- Save Map Data (Create or Update) --- const handleSave = useCallback(async () => { - if (!userId || !user?.idToken) { - setSaveStatus("Please log in to save."); + if (!isLoggedIn) { + setSaveStatus("Please log in to save changes."); return; } setSaveStatus("Saving..."); console.log(`Attempting to save map: ${currentMapId || '(new)'}`); - // --- Track if it's a new map being saved --- const isNewMapInitially = !currentMapId; - // --- End Track --- - // Prompt for name if it's a new map or untitled let mapNameToSave = currentMapName; if (currentMapName === 'Untitled Map') { const newName = prompt("Enter a name for this map:", currentMapName); - if (newName === null) { // User cancelled prompt - setSaveStatus(''); // Clear saving status + if (newName === null) { + setSaveStatus(''); return; } - mapNameToSave = newName.trim() || 'Untitled Map'; // Use new name or default - setCurrentMapName(mapNameToSave); // Update state immediately + mapNameToSave = newName.trim() || 'Untitled Map'; + setCurrentMapName(mapNameToSave); } if (!currentMapId) { - // This case should ideally not happen if handleNewMap always creates an ID first. - // But as a fallback, maybe call handleNewMap first? Or show an error. console.error("Save attempted without a currentMapId. Please create a new map first."); setSaveStatus("Error: Cannot save, no map selected/created."); - // OR potentially trigger handleNewMap here, though it might be confusing UX. - // await handleNewMap(); // This would create it, then the rest of save would update it immediately. return; } @@ -360,8 +346,8 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. const payload = { nodes, edges, - map_name: mapNameToSave, // Send the potentially updated name - map_id: currentMapId // Send currentMapId (null if new) + map_name: mapNameToSave, + map_id: currentMapId }; const result = await fetchData('course-map', { method: 'POST', @@ -374,48 +360,39 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. console.log("Map saved successfully:", result); setSaveStatus("Map saved!"); - const savedMapId = result?.map_id; // Use returned ID if new, else current + const savedMapId = result?.map_id; if (savedMapId) { - setCurrentMapId(savedMapId); // Update state if it was a new map + setCurrentMapId(savedMapId); - // Update specific map cache const mapCacheKey = getCacheKey('courseMap', userId, savedMapId); const updatedMapData = { nodes, edges, map_id: savedMapId, map_name: mapNameToSave, - last_updated: new Date().toISOString() // Add current timestamp + last_updated: new Date().toISOString() }; saveToCache(mapCacheKey, updatedMapData); - // --- Update mapList state directly if it was a new map --- if (isNewMapInitially) { const newMapEntry = { map_id: savedMapId, map_name: mapNameToSave, - last_updated: updatedMapData.last_updated // Use the same timestamp + last_updated: updatedMapData.last_updated }; setMapList(prevList => { - // Add the new map and re-sort by date descending const newList = [newMapEntry, ...prevList]; newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); - // Update cache for the list as well const listCacheKey = getCacheKey('courseMapList', userId); saveToCache(listCacheKey, newList); return newList; }); } else { - // If updating an existing map, just refresh the list from API - // to get potentially updated 'last_updated' timestamp and ensure consistency. - loadMapList(true); // Force refresh map list cache from API + loadMapList(true); } - // --- End Update --- } else if (!isNewMapInitially) { - // If it wasn't a new map but we didn't get an ID back (shouldn't happen on update success) - // still refresh the list just in case something changed (like the name) loadMapList(true); } @@ -425,23 +402,29 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. console.error("Failed to save course map:", error); setSaveStatus(`Error saving map: ${error.message}`); } - }, [userId, user?.idToken, nodes, edges, currentMapId, currentMapName, loadMapList, setMapList]); // Added setMapList dependency + }, [userId, user?.idToken, nodes, edges, currentMapId, currentMapName, loadMapList, setMapList, isLoggedIn]); - // --- Handle Map Selection Change --- const handleMapSelectChange = (event) => { + if (!isLoggedIn) { + loadSpecificMap(null); + return; + } const selectedId = event.target.value; if (selectedId === "__NEW__") { console.log("Selected [Untitled Map], resetting view."); loadSpecificMap(null); } else { - // Load existing map (check cache first) loadSpecificMap(selectedId); } }; - // --- Handle Delete Map --- const handleDeleteMap = useCallback(async () => { + if (!isLoggedIn) { + setSaveStatus("Please log in to delete maps."); + return; + } + if (!currentMapId || !userId || !user?.idToken) { setSaveStatus("No map selected to delete or not logged in."); return; @@ -451,70 +434,57 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. return; } - const mapToDeleteId = currentMapId; // Capture ID before state changes + const mapToDeleteId = currentMapId; const mapCacheKey = getCacheKey('courseMap', userId, mapToDeleteId); - const listCacheKey = getCacheKey('courseMapList', userId); // Cache key for the list + const listCacheKey = getCacheKey('courseMapList', userId); console.log(`[Delete Attempt] User ID: ${userId}, Map ID: ${mapToDeleteId}`); setSaveStatus("Deleting..."); try { - // --- Call Backend DELETE --- await fetchData(`course-map/${mapToDeleteId}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${user.idToken}` } }); - // --- Backend Delete Successful --- console.log("Map deleted successfully from backend."); setSaveStatus("Map deleted."); - // --- Frontend Cleanup --- - // 1. Remove from specific map cache removeFromCache(mapCacheKey); - // 2. Update mapList state and list cache let nextMapIdToLoad = null; setMapList(prevList => { const newList = prevList.filter(map => map.map_id !== mapToDeleteId); - // Determine which map to load next (e.g., the first one in the updated list) if (newList.length > 0) { - newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); // Re-sort just in case + newList.sort((a, b) => new Date(b.last_updated) - new Date(a.last_updated)); nextMapIdToLoad = newList[0].map_id; } - saveToCache(listCacheKey, newList); // Update list cache + saveToCache(listCacheKey, newList); return newList; }); - // 3. Load the next map (or reset if list is empty) if (nextMapIdToLoad) { loadSpecificMap(nextMapIdToLoad); } else { - // No maps left, reset to a new, unsaved state setNodes(defaultNodes); setEdges(defaultEdges); setCurrentMapId(null); setCurrentMapName('Untitled Map'); idCounter = 0; - // Optionally, call handleNewMap() if you want to immediately prompt for a name - // handleNewMap(); } - // --- End Frontend Cleanup --- setTimeout(() => setSaveStatus(''), 2000); } catch (error) { console.error(`[Delete Failed] User ID: ${userId}, Map ID: ${mapToDeleteId}`, error); setSaveStatus(`Error deleting map: ${error.message}`); - // Optionally, force refresh the list from API on error to ensure consistency loadMapList(true); } - }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, setMapList, setNodes, setEdges]); // Added setMapList, setNodes, setEdges + }, [userId, user?.idToken, currentMapId, currentMapName, loadMapList, loadSpecificMap, setMapList, setNodes, setEdges, isLoggedIn]); - // --- Other Callbacks (onConnect, addNode, startEditing, handleEditChange, saveEdit, onPaneClick) remain the same --- const onConnect = useCallback((connection) => { const newEdge = { ...connection, label: 'Prereq' }; setEdges((eds) => addEdge(newEdge, eds)); @@ -526,7 +496,12 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. x: reactFlowWrapper.current.clientWidth / 2, y: reactFlowWrapper.current.clientHeight / 3, }); - const newNode = { id: newNodeId, position, data: { label: `New Course ${idCounter}` } }; + const newNode = { + id: newNodeId, + type: 'courseNode', + position, + data: { label: `New Course ${idCounter}` } + }; setNodes((nds) => nds.concat(newNode)); }, [screenToFlowPosition, setNodes]); @@ -552,61 +527,73 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. }, [editingElement, editValue, setNodes, setEdges]); const onPaneClick = useCallback(() => saveEdit(), [saveEdit]); - // --- End Other Callbacks --- - // Render loading state for the whole map area + const nodeTypes = useMemo(() => ({ + courseNode: CustomCourseNode, + }), []); + if (isMapListLoading || isLoading) { - return

Loading course maps...

; + if (isLoggedIn) { + return

Loading course maps...

; + } } return (
{editingElement && } - {/* --- Map Management Bar --- */} + {!isLoggedIn && ( +
+ Please log in to save your course map or access saved maps. +
+ )} +
- {/* Display the current map name */} Editing: - - {currentMapName} {currentMapId ? '' : '(unsaved)'} + + {currentMapName} {currentMapId || !isLoggedIn ? '(unsaved)' : ''} - | {/* Separator */} + | - - {mapList.map(map => ( + {isLoggedIn && mapList.map(map => ( ))} - - - + + + - {saveStatus && {saveStatus}} + {saveStatus && {saveStatus}}
- {/* --- End Map Management Bar --- */} - {/* --- Instructions Bar --- */}
| Double-click to rename | Select + Backspace/Delete to remove | Drag handles to connect
- {/* --- End Instructions Bar --- */} - {/* Note about custom nodes remains the same */}
Note: To add multiple input/output handles, create a Custom Node component.
@@ -614,8 +601,7 @@ function CourseMapFlow({ user }) { // user object now contains id, idToken, etc. ); } -// Wrap with Provider -export default function CourseMap({ user }) { +export default function CourseMap({ user }) { return ( diff --git a/src/components/CustomCourseNode.jsx b/src/components/CustomCourseNode.jsx new file mode 100644 index 0000000..772a50a --- /dev/null +++ b/src/components/CustomCourseNode.jsx @@ -0,0 +1,45 @@ +import React, { memo } from 'react'; +import { Handle, Position } from 'reactflow'; + +const nodeStyle = { + padding: '12px 18px', + background: '#ffffff', + border: '1px solid #ddd', + borderRadius: '8px', + textAlign: 'center', + minWidth: '120px', + fontSize: '14px', + color: '#333', + fontFamily: '"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif', + boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', + position: 'relative', +}; + +const handleStyle = { + background: '#777', + width: '8px', + height: '8px', + border: '1px solid #fff', +}; + +const CustomCourseNode = ({ data }) => { + return ( +
+ +
{data.label}
+ +
+ ); +}; + +export default memo(CustomCourseNode); \ No newline at end of file diff --git a/src/components/PdfViewer.jsx b/src/components/PdfViewer.jsx index 4c467ec..0393569 100644 --- a/src/components/PdfViewer.jsx +++ b/src/components/PdfViewer.jsx @@ -1,21 +1,16 @@ import React from 'react'; -// Accept imageFilenames directly as a prop -function PdfViewer({ imageFilenames, isLoading, error, filename }) { // Added isLoading, error, filename for context messages +function PdfViewer({ imageFilenames, isLoading, error, filename }) { - // Render content based on props return (
- {/* Show messages passed from parent */} - {!filename &&

Select a major to view the agreement.

} - {isLoading &&

Loading agreement images...

} + {!filename &&

Select a major/department to view the agreement.

} {error &&

Error loading agreement: {error}

} {!isLoading && !error && filename && (!imageFilenames || imageFilenames.length === 0) && (

No images found or extracted for this agreement.

)} - {/* --- Scrollable Image Container --- */} {!isLoading && !error && filename && imageFilenames && imageFilenames.length > 0 && (
{`Page
@@ -39,7 +34,6 @@ function PdfViewer({ imageFilenames, isLoading, error, filename }) { // Added is })}
)} - {/* --- End Scrollable Image Container --- */}
); } diff --git a/src/hooks/useAcademicYears.js b/src/hooks/useAcademicYears.js new file mode 100644 index 0000000..9912fb3 --- /dev/null +++ b/src/hooks/useAcademicYears.js @@ -0,0 +1,102 @@ +import { useState, useEffect, useRef } from 'react'; +import { fetchData } from '../services/api'; + +const LOCAL_STORAGE_PREFIX = 'ctaCache_'; + +export function useAcademicYears(selectedSendingInstitutions, selectedReceivingId) { + const [academicYears, setAcademicYears] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const cacheRef = useRef({}); + const senderIds = selectedSendingInstitutions.map(s => s.id); + const senderIdsString = senderIds.sort().join(','); + const contextKey = `${senderIdsString}_${selectedReceivingId}`; + + useEffect(() => { + let isMounted = true; + + if (senderIds.length === 0 || !selectedReceivingId) { + setAcademicYears({}); + setIsLoading(false); + setError(null); + return; + } + + const fetchCommonYears = async () => { + if (!isMounted) return; + setIsLoading(true); + setError(null); + setAcademicYears({}); + + const localStorageKey = `${LOCAL_STORAGE_PREFIX}years_intersection_${contextKey}`; + const memoryCacheKey = `years_${contextKey}`; + if (cacheRef.current[memoryCacheKey]) { + console.log(`In-memory cache hit for years intersection: ${contextKey}`); + setAcademicYears(cacheRef.current[memoryCacheKey]); + setIsLoading(false); + return; + } + try { + const cachedDataString = localStorage.getItem(localStorageKey); + if (cachedDataString) { + console.log(`LocalStorage hit for years intersection (${localStorageKey})`); + const cachedData = JSON.parse(cachedDataString); + cacheRef.current[memoryCacheKey] = cachedData; + setAcademicYears(cachedData); + setIsLoading(false); + return; + } + } catch (e) { + console.error(`Error reading years intersection from localStorage (${localStorageKey}):`, e); + localStorage.removeItem(localStorageKey); + } + console.log(`Cache miss for years intersection (${contextKey}). Fetching...`); + try { + const data = await fetchData(`academic-years?sendingId=${senderIdsString}&receivingId=${selectedReceivingId}`); + + if (!isMounted) return; + + let finalData = {}; + let warnings = null; + if (data && data.years !== undefined) { + finalData = data.years || {}; + warnings = data.warnings; + if (warnings) console.warn("Partial fetch failure for academic years:", warnings); + } else { + finalData = data || {}; + } + + if (Object.keys(finalData).length === 0 && !warnings) { + setError("No common academic years found for the selected combination."); + } + + setAcademicYears(finalData); + cacheRef.current[memoryCacheKey] = finalData; + try { + localStorage.setItem(localStorageKey, JSON.stringify(finalData)); + } catch (e) { + console.error(`Error writing years intersection to localStorage (${localStorageKey}):`, e); + } + + } catch (err) { + console.error("Error fetching common academic years:", err); + if (isMounted) { + setError(`Failed to load common academic years: ${err.message}`); + setAcademicYears({}); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + fetchCommonYears(); + + return () => { + isMounted = false; + }; + }, [senderIdsString, selectedReceivingId]); + + return { academicYears, isLoading, error }; +} diff --git a/src/hooks/useAgreementData.js b/src/hooks/useAgreementData.js new file mode 100644 index 0000000..0f658b1 --- /dev/null +++ b/src/hooks/useAgreementData.js @@ -0,0 +1,403 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { fetchData } from '../services/api'; + +const IGETC_ID = 'IGETC'; +const LOCAL_STORAGE_PREFIX = 'ctaCache_'; + +export function useAgreementData(initialSendingId, receivingId, yearId, user, allSelectedSendingInstitutions) { + const [selectedCategory, setSelectedCategory] = useState('major'); + const [majors, setMajors] = useState({}); + const [isLoadingMajors, setIsLoadingMajors] = useState(true); + const [error, setError] = useState(null); + const [pdfError, setPdfError] = useState(null); + const [selectedMajorKey, setSelectedMajorKey] = useState(null); + const [selectedMajorName, setSelectedMajorName] = useState(''); + const [isLoadingPdf, setIsLoadingPdf] = useState(false); + const [majorSearchTerm, setMajorSearchTerm] = useState(''); + const [hasMajorsAvailable, setHasMajorsAvailable] = useState(true); + const [hasDepartmentsAvailable, setHasDepartmentsAvailable] = useState(true); + const [isLoadingAvailability, setIsLoadingAvailability] = useState(true); + const [agreementData, setAgreementData] = useState([]); + const [allAgreementsImageFilenamesState, setAllAgreementsImageFilenamesState] = useState([]); + const [activeTabIndex, setActiveTabIndex] = useState(0); + const [imagesForActivePdf, setImagesForActivePdf] = useState([]); + const imageCacheRef = useRef({}); + const categoryCacheRef = useRef({}); + const currentAgreement = activeTabIndex >= 0 && activeTabIndex < agreementData.length ? agreementData[activeTabIndex] : null; + const currentPdfFilename = currentAgreement?.pdfFilename; + + useEffect(() => { + if (!initialSendingId || !receivingId || !yearId) { + setIsLoadingAvailability(false); + setHasMajorsAvailable(false); + setHasDepartmentsAvailable(false); + return; + } + setIsLoadingAvailability(true); + setHasMajorsAvailable(false); + setHasDepartmentsAvailable(false); + const availabilityContextKey = `availability_${initialSendingId}-${receivingId}-${yearId}`; + + const checkAvailability = async (category) => { + const availabilityLocalStorageKey = `${availabilityContextKey}_${category}`; + try { + const cachedAvailability = localStorage.getItem(availabilityLocalStorageKey); + if (cachedAvailability !== null) { + console.log(`Cache hit for availability (${availabilityLocalStorageKey}): ${cachedAvailability}`); + return cachedAvailability === 'true'; + } + } catch (e) { + console.error("Error reading availability from localStorage:", e); + localStorage.removeItem(availabilityLocalStorageKey); + } + console.log(`Cache miss for availability (${availabilityLocalStorageKey}). Fetching...`); + try { + const data = await fetchData(`/majors?sendingId=${initialSendingId}&receivingId=${receivingId}&academicYearId=${yearId}&categoryCode=${category}`); + const exists = Object.keys(data || {}).length > 0; + try { + localStorage.setItem(availabilityLocalStorageKey, exists ? 'true' : 'false'); + } catch (e) { + console.error("Error writing availability to localStorage:", e); + } + return exists; + } catch (err) { + console.error(`Error checking availability for ${category}:`, err); + return false; + } + }; + + Promise.all([checkAvailability('major'), checkAvailability('dept')]) + .then(([majorsExist, deptsExist]) => { + setHasMajorsAvailable(majorsExist); + setHasDepartmentsAvailable(deptsExist); + if (selectedCategory === 'major' && !majorsExist && deptsExist) { + setSelectedCategory('dept'); + } else if (selectedCategory === 'dept' && !deptsExist && majorsExist) { + setSelectedCategory('major'); + } + }) + .finally(() => setIsLoadingAvailability(false)); + + }, [initialSendingId, receivingId, yearId, selectedCategory]); + + useEffect(() => { + if (isLoadingAvailability || !initialSendingId || !receivingId || !yearId) { + if (!initialSendingId || !receivingId || !yearId) setError("Missing selection criteria."); + setMajors({}); + setIsLoadingMajors(false); + return; + } + if ((selectedCategory === 'major' && !hasMajorsAvailable) || (selectedCategory === 'dept' && !hasDepartmentsAvailable)) { + setError(`No ${selectedCategory}s found for the selected combination.`); + setIsLoadingMajors(false); + setMajors({}); + return; + } + const contextKey = `${initialSendingId}-${receivingId}-${yearId}`; + const categoryKey = selectedCategory; + const localStorageKey = `${LOCAL_STORAGE_PREFIX}${contextKey}_${categoryKey}`; + try { + const cachedDataString = localStorage.getItem(localStorageKey); + if (cachedDataString) { + console.log(`LocalStorage hit for ${localStorageKey}.`); + const cachedData = JSON.parse(cachedDataString); + if (!categoryCacheRef.current[contextKey]) { + categoryCacheRef.current[contextKey] = {}; + } + categoryCacheRef.current[contextKey][categoryKey] = cachedData; + setMajors(cachedData); + setError(null); + setIsLoadingMajors(false); + return; + } + } catch (e) { + console.error("Error reading from localStorage:", e); + localStorage.removeItem(localStorageKey); + } + if (categoryCacheRef.current[contextKey]?.[categoryKey]) { + console.log(`In-memory cache hit for ${categoryKey} in context ${contextKey}.`); + setMajors(categoryCacheRef.current[contextKey][categoryKey]); + setError(null); + setIsLoadingMajors(false); + return; + } + console.log(`Cache miss for ${localStorageKey}. Fetching...`); + setIsLoadingMajors(true); + setError(null); + setMajors({}); + + const fetchCategoryData = async () => { + try { + const data = await fetchData(`/majors?sendingId=${initialSendingId}&receivingId=${receivingId}&academicYearId=${yearId}&categoryCode=${selectedCategory}`); + const fetchedData = data || {}; + setMajors(fetchedData); + if (!categoryCacheRef.current[contextKey]) { + categoryCacheRef.current[contextKey] = {}; + } + categoryCacheRef.current[contextKey][categoryKey] = fetchedData; + try { + localStorage.setItem(localStorageKey, JSON.stringify(fetchedData)); + console.log(`Fetched and cached ${categoryKey}s in localStorage (${localStorageKey}).`); + } catch (e) { + console.error("Error writing to localStorage:", e); + } + + } catch (err) { + console.error(`Error fetching ${selectedCategory}s:`, err); + setError(`Failed to load ${selectedCategory}s.`); + setMajors({}); + } finally { + setIsLoadingMajors(false); + } + }; + + fetchCategoryData(); + }, [initialSendingId, receivingId, yearId, selectedCategory, isLoadingAvailability, hasMajorsAvailable, hasDepartmentsAvailable]); + + const fetchAgreementDetailsAndImages = useCallback(async (majorKey) => { + if (!majorKey || allSelectedSendingInstitutions.length === 0 || !receivingId || !yearId) { + console.warn("Skipping agreement fetch: Missing major key or context."); + setAgreementData([]); + setAllAgreementsImageFilenamesState([]); + setImagesForActivePdf([]); + setActiveTabIndex(0); + setPdfError("Select a major/department and ensure all institutions/year are set."); + setIsLoadingPdf(false); + return; + } + + console.log(`Fetching agreements and images for majorKey: ${majorKey}`); + setIsLoadingPdf(true); + setPdfError(null); + setAgreementData([]); + setAllAgreementsImageFilenamesState([]); + setImagesForActivePdf([]); + const contextSendingId = initialSendingId || allSelectedSendingInstitutions[0]?.id; + let combinedAgreements = []; + let initialActiveIndex = 0; + + try { + let igetcAgreement = null; + if (user && user.idToken && contextSendingId && yearId) { + try { + const igetcResponse = await fetchData(`igetc-agreement?sendingId=${contextSendingId}&academicYearId=${yearId}`, { + headers: { 'Authorization': `Bearer ${user.idToken}` } + }); + if (igetcResponse?.pdfFilename) { + igetcAgreement = { + sendingId: IGETC_ID, + sendingName: 'IGETC', + pdfFilename: igetcResponse.pdfFilename, + isIgetc: true + }; + console.log("IGETC agreement found:", igetcAgreement.pdfFilename); + } else { + console.warn("No IGETC PDF filename received:", igetcResponse?.error || "Empty response"); + } + } catch (err) { + console.error("Error fetching IGETC filename:", err); + } + } else { + console.log("Skipping IGETC fetch: User not logged in or missing context."); + } + const sendingIds = allSelectedSendingInstitutions.map(inst => inst.id); + const response = await fetchData('articulation-agreements', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sending_ids: sendingIds, receiving_id: receivingId, year_id: yearId, major_key: majorKey }) + }); + + let fetchedAgreements = []; + if (response?.agreements) { + fetchedAgreements = response.agreements.map(a => ({ + ...a, + sendingName: allSelectedSendingInstitutions.find(inst => inst.id === a.sendingId)?.name || `ID ${a.sendingId}`, + isIgetc: false + })); + console.log(`Fetched ${fetchedAgreements.length} major agreements.`); + } else { + console.error("Invalid response format for major agreements:", response?.error || "No agreements array"); + if (!igetcAgreement) { + throw new Error(response?.error || "Failed to load major agreements and no IGETC found."); + } + } + combinedAgreements = igetcAgreement ? [igetcAgreement, ...fetchedAgreements] : fetchedAgreements; + setAgreementData(combinedAgreements); + + if (combinedAgreements.length === 0) { + setPdfError("No agreements found for this major/department."); + setIsLoadingPdf(false); + setAllAgreementsImageFilenamesState([]); + return; + } + console.log("Fetching images for all agreements..."); + const imageFetchPromises = combinedAgreements + .filter(a => a.pdfFilename) + .map(async (agreement) => { + const filename = agreement.pdfFilename; + if (imageCacheRef.current[filename]) { + console.log(`Cache hit for images: ${filename}`); + return { filename, images: imageCacheRef.current[filename] }; + } + console.log(`Cache miss, fetching images for: ${filename}`); + try { + const imgResponse = await fetchData(`pdf-images/${encodeURIComponent(filename)}`); + if (imgResponse?.image_filenames) { + imageCacheRef.current[filename] = imgResponse.image_filenames; + console.log(`Successfully fetched and cached images for: ${filename}`); + return { filename, images: imgResponse.image_filenames }; + } + console.warn(`No image filenames received for ${filename}`); + imageCacheRef.current[filename] = []; + return { filename, images: [] }; + } catch (imgErr) { + console.error(`Error fetching images for ${filename}:`, imgErr); + imageCacheRef.current[filename] = []; + return { filename, images: [] }; + } + }); + await Promise.allSettled(imageFetchPromises); + console.log("All image fetch attempts completed."); + const newAllFilenames = combinedAgreements.reduce((acc, agreement) => { + if (agreement.pdfFilename && imageCacheRef.current[agreement.pdfFilename]) { + acc.push(...imageCacheRef.current[agreement.pdfFilename]); + } + return acc; + }, []); + const uniqueFilenames = [...new Set(newAllFilenames)]; + setAllAgreementsImageFilenamesState(uniqueFilenames); + console.log("Derived and set allAgreementsImageFilenamesState:", uniqueFilenames); + const firstRealAgreementIndex = combinedAgreements.findIndex(a => !a.isIgetc); + initialActiveIndex = firstRealAgreementIndex !== -1 ? firstRealAgreementIndex : 0; + setActiveTabIndex(initialActiveIndex); + + } catch (err) { + console.error("Error during agreement details/images fetch:", err); + setPdfError(`Failed to load agreement data: ${err.message}`); + setAgreementData([]); + setImagesForActivePdf([]); + setActiveTabIndex(0); + setAllAgreementsImageFilenamesState([]); + } finally { + setIsLoadingPdf(false); + console.log("Finished fetchAgreementDetailsAndImages. isLoadingPdf: false"); + } + }, [allSelectedSendingInstitutions, receivingId, yearId, initialSendingId, user?.idToken]); + + useEffect(() => { + if (agreementData.length === 0 || activeTabIndex < 0 || activeTabIndex >= agreementData.length) { + setImagesForActivePdf([]); + return; + } + + const activeAgreement = agreementData[activeTabIndex]; + const filename = activeAgreement?.pdfFilename; + + console.log(`Active tab changed to ${activeTabIndex}. Agreement: ${activeAgreement?.sendingName}, PDF: ${filename}`); + + if (filename) { + const cachedImages = imageCacheRef.current[filename]; + if (cachedImages) { + setImagesForActivePdf(cachedImages); + console.log(`Displaying ${cachedImages.length} cached images for active tab ${activeTabIndex} (${filename}).`); + } else { + setImagesForActivePdf([]); + console.warn(`Images not found in cache for active tab ${activeTabIndex} (${filename}). This might indicate a fetch error for this specific PDF.`); + } + } else { + setImagesForActivePdf([]); + console.log(`No PDF filename for active tab ${activeTabIndex}. Clearing images.`); + } + }, [activeTabIndex, agreementData]); + + useEffect(() => { + console.log("Core context changed (URL params), resetting selections and agreement data."); + setSelectedMajorKey(null); setSelectedMajorName(''); + setAgreementData([]); + setActiveTabIndex(0); + setImagesForActivePdf([]); + setPdfError(null); + setIsLoadingPdf(false); + imageCacheRef.current = {}; + console.log("Image cache cleared due to context change."); + categoryCacheRef.current = {}; + console.log("In-memory category cache cleared due to context change."); + setHasMajorsAvailable(true); + setHasDepartmentsAvailable(true); + setIsLoadingAvailability(true); + setMajors({}); + setError(null); + setIsLoadingMajors(true); + setAllAgreementsImageFilenamesState([]); + + }, [initialSendingId, receivingId, yearId]); + + const handleMajorSelect = useCallback((majorKey, majorName) => { + if (majorKey === selectedMajorKey) return; + + console.log("Major selected:", majorKey, majorName); + setSelectedMajorKey(majorKey); + setSelectedMajorName(majorName); + setAgreementData([]); + setImagesForActivePdf([]); + setActiveTabIndex(0); + setPdfError(null); + fetchAgreementDetailsAndImages(majorKey); + + }, [selectedMajorKey, fetchAgreementDetailsAndImages]); + + const handleCategoryChange = useCallback((event) => { + const newCategory = event.target.value; + if (newCategory === selectedCategory) return; + + console.log("Category changed to:", newCategory); + setSelectedCategory(newCategory); + setSelectedMajorKey(null); setSelectedMajorName(''); + setMajorSearchTerm(''); + setAgreementData([]); + setImagesForActivePdf([]); + setActiveTabIndex(0); + setPdfError(null); + setIsLoadingPdf(false); + }, [selectedCategory]); + + const handleTabClick = useCallback((index) => { + if (index === activeTabIndex) return; + console.log("Tab clicked:", index, "Setting active tab index."); + setActiveTabIndex(index); + }, [activeTabIndex]); + + const filteredMajors = useMemo(() => { + const lowerCaseSearchTerm = majorSearchTerm.toLowerCase(); + if (typeof majors !== 'object' || majors === null) return []; + return Object.entries(majors).filter(([name]) => + name.toLowerCase().includes(lowerCaseSearchTerm) + ); + }, [majors, majorSearchTerm]); + + + return { + selectedCategory, + majors, + isLoadingMajors, + error, + pdfError, + selectedMajorKey, + selectedMajorName, + isLoadingPdf, + majorSearchTerm, + hasMajorsAvailable, + hasDepartmentsAvailable, + isLoadingAvailability, + agreementData, + allAgreementsImageFilenames: allAgreementsImageFilenamesState, + activeTabIndex, + imagesForActivePdf, + currentPdfFilename, + filteredMajors, + handleMajorSelect, + handleCategoryChange, + handleTabClick, + setMajorSearchTerm, + }; +} diff --git a/src/hooks/useChat.js b/src/hooks/useChat.js new file mode 100644 index 0000000..8a96943 --- /dev/null +++ b/src/hooks/useChat.js @@ -0,0 +1,212 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { fetchData } from '../services/api'; + +export function useChat( + imageFilenames, + selectedMajorName, + user, + sendingInstitutionId, + allSendingInstitutionIds, + receivingInstitutionId, + academicYearId +) { + const [userInput, setUserInput] = useState(''); + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [chatError, setChatError] = useState(null); + const initialAnalysisSentRef = useRef(false); + + useEffect(() => { + console.log("Current message history:", messages); + }, [messages]); + + useEffect(() => { + setMessages([]); + setUserInput(''); + setChatError(null); + initialAnalysisSentRef.current = false; + console.log("DEBUG: Chat cleared and initialAnalysisSentRef reset due to context/user change."); + }, [imageFilenames, selectedMajorName, user, sendingInstitutionId, receivingInstitutionId, academicYearId]); + + useEffect(() => { + console.log("DEBUG: Initial analysis effect triggered."); + console.log("DEBUG: User object:", user); + console.log("DEBUG: User ID:", user ? user.id : "N/A"); + console.log("DEBUG: User ID Token:", user ? (user.idToken ? "Present" : "MISSING") : "N/A"); + console.log("DEBUG: imageFilenames (for initial analysis):", imageFilenames); + console.log("DEBUG: initialAnalysisSentRef.current:", initialAnalysisSentRef.current); + console.log("DEBUG: isLoading:", isLoading); + + if (!user) { + console.log("DEBUG: Skipping initial analysis - User not logged in."); + initialAnalysisSentRef.current = false; + return; + } + const shouldSend = imageFilenames && imageFilenames.length > 0 && !initialAnalysisSentRef.current && !isLoading; + console.log("DEBUG: Should send initial analysis?", shouldSend); + if (shouldSend && false) { + const sendInitialAnalysis = async () => { + if (!user || !user.idToken) { + console.error("DEBUG: Cannot send initial analysis: User logged out or token missing just before sending."); + setChatError("Authentication error. Please log in again."); + setMessages([{ type: 'system', text: "Authentication error. Please log in again." }]); + initialAnalysisSentRef.current = false; + return; + } + + console.log("DEBUG: Conditions met. Calling sendInitialAnalysis function..."); + setIsLoading(true); + setChatError(null); + initialAnalysisSentRef.current = true; + console.log("DEBUG: initialAnalysisSentRef.current set to true."); + console.log("DEBUG: Context for prompt - academicYearId:", academicYearId); + console.log("DEBUG: Context for prompt - sendingInstitutionId:", sendingInstitutionId); + console.log("DEBUG: Context for prompt - receivingInstitutionId:", receivingInstitutionId); + console.log("DEBUG: Context for prompt - selectedMajorName:", selectedMajorName); + console.log("DEBUG: Context for prompt - allSendingInstitutionIds:", allSendingInstitutionIds); + + const currentContextInfo = `The user is viewing an articulation agreement for the academic year ${academicYearId || 'N/A'} between sending institution ID ${sendingInstitutionId || 'N/A'} (the 'current' agreement) and receiving institution ID ${receivingInstitutionId || 'N/A'}. The selected major/department is "${selectedMajorName || 'N/A'}".`; + const overallContextInfo = allSendingInstitutionIds && allSendingInstitutionIds.length > 1 + ? `Note: The user originally selected multiple sending institutions (IDs: ${allSendingInstitutionIds.join(', ')}). Images for all selected agreements have been provided.` + : 'Only one sending institution was selected.'; + + const initialPrompt = `You are a helpful, knowledgeable, and supportive college counselor specializing in helping community college students successfully transfer to four-year universities. Your guidance should be clear, encouraging, and personalized based on each student's academic goals, major, preferred universities, and career aspirations. You provide information about transfer requirements, application tips, deadlines, articulation agreements, financial aid, scholarships, and campus life insights. Always empower students with accurate, up-to-date information and a positive, motivating tone. If you don't know an answer, offer to help them find resources or suggest next steps. + +**Current Context:** ${currentContextInfo} +${overallContextInfo ? `**Overall Context:** ${overallContextInfo}` : ''} + +Analyze the provided agreement images thoroughly. Perform the following steps: +1. **Focus on the Current Context:** Analyze the agreement for the major between the **current sending institution** and the receiving institution. +2. **Explicitly state the current context:** Start your response with: "Analyzing the agreement for the **[Major Name]** major between **[Current Sending Institution Name]** and **[Receiving Institution Name]** for the **[academic year name]** academic year." (Replace bracketed placeholders with actual names/year). +3. **Provide a detailed, accurate, yet concise summary** of the key details for the **current** agreement. This summary should include: + * All articulated courses (sending course -> receiving course). + * Any specific GPA requirements mentioned. + * Any other critical requirements or notes from the agreement. + * Crucially, identify any required courses for the major at the receiving institution that are **not articulated** by the current sending institution according to this agreement. List these clearly. +4. **Compare Articulation (If Applicable):** If you identified non-articulated courses in step 3 AND other sending institutions were selected (see Overall Context), examine the provided images for the **other** agreements (Sending IDs: ${allSendingInstitutionIds?.filter(id => id !== sendingInstitutionId).join(', ') || 'None'}). For each non-articulated course from the current agreement, state whether it **is articulated** by any of the **other** sending institutions based on their respective agreements. Present this comparison clearly, perhaps in a separate section or list (e.g., "Comparison with Other Selected Colleges:"). +5. **Suggest Next Steps:** Conclude with relevant advice or next steps for the student based on the analysis and comparison. +6. **Offer Education Plan:** After providing the analysis and next steps, ask the user: "Would you like me to generate a potential 2-year education plan based on this information? If yes, I will outline courses semester-by-semester (Year 1 Fall, Year 1 Spring, Year 1 Summer, Year 2 Fall, Year 2 Spring, Year 2 Summer) aiming for approximately 4 classes per Fall/Spring semester and 2 classes during the Summer. This plan will incorporate courses from the **[Sending Institution(s) names]** to meet the requirements for the **[Receiving Institution name]**. For each course, I will include its *unit count* and *check for common prerequisites* using my knowledge base. If the prerequisite information isn't readily available, I can perform a web search to find it. Importantly, I will ensure that any identified prerequisite course is placed in a semester *before* the course that requires it." + +**Formatting:** Use bullet points (* or -) for lists, **bold** for emphasis (especially names and key terms), and \`italic\` for course codes/titles. Ensure the summary in step 3 is well-organized and easy to read.`; + + setMessages([{ type: 'system', text: "Analyzing agreements and generating summary..." }]); + + const payload = { + new_message: initialPrompt, + history: [], + image_filenames: imageFilenames + }; + + try { + console.log("DEBUG: Sending initial analysis to /chat with payload:", payload); + const response = await fetchData('chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.idToken}` + }, + body: JSON.stringify(payload) + }); + console.log("DEBUG: Received response from /chat:", response); + + if (response && response.reply) { + setMessages([{ type: 'bot', text: response.reply }]); + } else { + if (response?.error?.includes("Authorization")) { + throw new Error("Authorization token missing or invalid"); + } + throw new Error(response?.error || "No reply received for initial analysis."); + } + } catch (err) { + console.error("DEBUG: Initial analysis API error:", err); + const errorMsg = `Failed initial analysis: ${err.message}`; + setChatError(errorMsg); + setMessages([{ type: 'system', text: `Error during analysis: ${err.message}` }]); + if (err.message.includes("Authorization")) { + console.log("DEBUG: Resetting initialAnalysisSentRef due to Authorization error."); + initialAnalysisSentRef.current = false; + } else { + console.log("DEBUG: Keeping initialAnalysisSentRef true despite non-auth error."); + } + } finally { + console.log("DEBUG: Initial analysis finished (success or error). Setting isLoading to false."); + setIsLoading(false); + } + }; + + sendInitialAnalysis(); + } else { + console.log("DEBUG: Conditions not met for sending initial analysis."); + if (!imageFilenames || imageFilenames.length === 0) console.log("Reason: No image filenames provided."); + if (initialAnalysisSentRef.current) console.log("Reason: Initial analysis already sent."); + if (isLoading) console.log("Reason: Already loading."); + } + }, [imageFilenames, isLoading, selectedMajorName, sendingInstitutionId, allSendingInstitutionIds, receivingInstitutionId, academicYearId, user]); + + const handleSend = useCallback(async () => { + if (!userInput.trim() || isLoading || !user || !user.idToken) { + if (!user || !user.idToken) { + console.error("Cannot send message: User not logged in or token missing."); + setChatError("Authentication error. Please log in again."); + setMessages(prev => [...prev, { type: 'system', text: "Authentication error. Please log in again." }]); + } + return; + } + + const currentInput = userInput; + const currentHistory = [...messages]; + + setMessages(prev => [...prev, { type: 'user', text: currentInput }]); + setUserInput(''); + setIsLoading(true); + setChatError(null); + + const apiHistory = currentHistory + .filter(msg => msg.type === 'user' || msg.type === 'bot') + .map(msg => ({ + role: msg.type === 'bot' ? 'assistant' : msg.type, + content: msg.text + })); + const payload = { + new_message: currentInput, + history: apiHistory, + image_filenames: imageFilenames + }; + + try { + console.log("Sending to /chat:", JSON.stringify(payload)); + const response = await fetchData('chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${user.idToken}` + }, + body: JSON.stringify(payload) + }); + + if (response && response.reply) { + setMessages(prev => [...prev, { type: 'bot', text: response.reply }]); + } else { + if (response?.error?.includes("Authorization")) { + throw new Error("Authorization token missing or invalid"); + } + throw new Error(response?.error || "No reply received or unexpected response format."); + } + } catch (err) { + console.error("Chat API error:", err); + setChatError(`Failed to get response: ${err.message}`); + setMessages(prev => [...prev, { type: 'system', text: `Error: ${err.message}` }]); + } finally { + setIsLoading(false); + } + }, [userInput, isLoading, user, messages, imageFilenames]); + + return { + userInput, + setUserInput, + messages, + isLoading, + chatError, + handleSend + }; +} diff --git a/src/hooks/useInstitutionData.js b/src/hooks/useInstitutionData.js new file mode 100644 index 0000000..6b5f53a --- /dev/null +++ b/src/hooks/useInstitutionData.js @@ -0,0 +1,60 @@ +import { useState, useEffect } from 'react'; +import { fetchData } from '../services/api'; + +const CACHE_KEY = "institutions"; + +export function useInstitutionData() { + const [institutions, setInstitutions] = useState({}); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let isMounted = true; + setIsLoading(true); + setError(null); + try { + const cachedData = localStorage.getItem(CACHE_KEY); + if (cachedData) { + const parsedData = JSON.parse(cachedData); + console.log("Loaded institutions from cache:", CACHE_KEY); + if (isMounted) { + setInstitutions(parsedData); + setIsLoading(false); + } + return; + } + } catch (e) { + console.error("Error loading institutions from cache:", e); + localStorage.removeItem(CACHE_KEY); + } + fetchData('institutions') + .then(data => { + if (isMounted) { + setInstitutions(data); + try { + localStorage.setItem(CACHE_KEY, JSON.stringify(data)); + console.log("Institutions cached successfully:", CACHE_KEY); + } catch (e) { + console.error("Error caching institutions:", e); + } + } + }) + .catch(err => { + if (isMounted) { + setError(`Failed to load institutions: ${err.message}`); + setInstitutions({}); + } + }) + .finally(() => { + if (isMounted) { + setIsLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, []); + + return { institutions, isLoading, error }; +} diff --git a/src/hooks/useReceivingInstitutions.js b/src/hooks/useReceivingInstitutions.js new file mode 100644 index 0000000..5b95768 --- /dev/null +++ b/src/hooks/useReceivingInstitutions.js @@ -0,0 +1,108 @@ +import { useState, useEffect, useRef } from 'react'; +import { fetchData } from '../services/api'; + +const LOCAL_STORAGE_PREFIX = 'ctaCache_'; + +export function useReceivingInstitutions(selectedSendingInstitutions) { + const [availableReceivingInstitutions, setAvailableReceivingInstitutions] = useState({}); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const cacheRef = useRef({}); + + const senderIds = selectedSendingInstitutions.map(s => s.id); + const senderIdsString = senderIds.sort().join(','); + + useEffect(() => { + let isMounted = true; + + if (senderIds.length === 0) { + setAvailableReceivingInstitutions({}); + setIsLoading(false); + setError(null); + return; + } + + const fetchCommonReceiving = async () => { + if (!isMounted) return; + setIsLoading(true); + setError(null); + setAvailableReceivingInstitutions({}); + + const localStorageKey = `${LOCAL_STORAGE_PREFIX}receiving_intersection_${senderIdsString}`; + const memoryCacheKey = `receiving_${senderIdsString}`; + + if (cacheRef.current[memoryCacheKey]) { + console.log(`In-memory cache hit for receiving intersection: ${senderIdsString}`); + setAvailableReceivingInstitutions(cacheRef.current[memoryCacheKey]); + setIsLoading(false); + return; + } + + try { + const cachedDataString = localStorage.getItem(localStorageKey); + if (cachedDataString) { + console.log(`LocalStorage hit for receiving intersection (${localStorageKey})`); + const cachedData = JSON.parse(cachedDataString); + cacheRef.current[memoryCacheKey] = cachedData; + setAvailableReceivingInstitutions(cachedData); + setIsLoading(false); + return; + } + } catch (e) { + console.error(`Error reading receiving intersection from localStorage (${localStorageKey}):`, e); + localStorage.removeItem(localStorageKey); + } + + console.log(`Cache miss for receiving intersection (${senderIdsString}). Fetching...`); + try { + const data = await fetchData(`receiving-institutions?sendingId=${senderIdsString}`); + + if (!isMounted) return; + + let finalData = {}; + let warnings = null; + + if (data && data.institutions !== undefined) { + finalData = data.institutions || {}; + warnings = data.warnings; + if (warnings) console.warn("Partial fetch failure for receiving institutions:", warnings); + } else { + finalData = data || {}; + } + + if (Object.keys(finalData).length === 0 && !warnings) { + setError("No common receiving institutions found for the selected sending institutions."); + } + + setAvailableReceivingInstitutions(finalData); + cacheRef.current[memoryCacheKey] = finalData; + + try { + localStorage.setItem(localStorageKey, JSON.stringify(finalData)); + } catch (e) { + console.error(`Error writing receiving intersection to localStorage (${localStorageKey}):`, e); + } + + } catch (err) { + console.error("Error fetching common receiving institutions:", err); + if (isMounted) { + setError(`Failed to load common receiving institutions: ${err.message}`); + setAvailableReceivingInstitutions({}); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + fetchCommonReceiving(); + + return () => { + isMounted = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [senderIdsString]); + + return { availableReceivingInstitutions, isLoading, error }; +} diff --git a/src/hooks/useResizeHandler.js b/src/hooks/useResizeHandler.js new file mode 100644 index 0000000..f877885 --- /dev/null +++ b/src/hooks/useResizeHandler.js @@ -0,0 +1,66 @@ +import { useState, useRef, useCallback, useEffect } from 'react'; + +export function useResizeHandler(initialWidth, minColWidth, fixedMajorsWidth, isMajorsVisibleRef) { + const [chatColumnWidth, setChatColumnWidth] = useState(initialWidth); + const isResizingRef = useRef(false); + const dividerRef = useRef(null); + const containerRef = useRef(null); + const dividerWidth = 1; + + const handleMouseMove = useCallback((e) => { + if (!isResizingRef.current || !containerRef.current) { + return; + } + + const containerRect = containerRef.current.getBoundingClientRect(); + const mouseX = e.clientX; + const containerLeft = containerRect.left; + const totalWidth = containerRect.width; + const gapWidth = 16; + const currentVisibility = isMajorsVisibleRef.current; + const majorsEffectiveWidth = currentVisibility ? fixedMajorsWidth : 0; + const gap1EffectiveWidth = currentVisibility ? gapWidth : 0; + const chatStartOffset = majorsEffectiveWidth + gap1EffectiveWidth; + let newChatWidth = mouseX - containerLeft - chatStartOffset; + const maxChatWidth = totalWidth - chatStartOffset - minColWidth - gapWidth - dividerWidth; + newChatWidth = Math.max(minColWidth, Math.min(newChatWidth, maxChatWidth)); + + setChatColumnWidth(newChatWidth); + }, [minColWidth, fixedMajorsWidth, isMajorsVisibleRef]); + + const handleMouseUp = useCallback(() => { + if (isResizingRef.current) { + isResizingRef.current = false; + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + document.body.style.cursor = ''; + document.body.style.userSelect = ''; + } + }, [handleMouseMove]); + + useEffect(() => { + return () => { + if (isResizingRef.current) { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + } + }; + }, [handleMouseMove, handleMouseUp]); + + const handleMouseDown = useCallback((e) => { + e.preventDefault(); + isResizingRef.current = true; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }, [handleMouseMove, handleMouseUp]); + + return { + chatColumnWidth, + setChatColumnWidth, + dividerRef, + containerRef, + handleMouseDown, + }; +} diff --git a/src/hooks/useUsageStatus.js b/src/hooks/useUsageStatus.js new file mode 100644 index 0000000..b1e5d59 --- /dev/null +++ b/src/hooks/useUsageStatus.js @@ -0,0 +1,83 @@ +import { useState, useEffect } from 'react'; +import { fetchData } from '../services/api'; + +function formatRemainingTime(resetTimestamp) { + if (!resetTimestamp) return ''; + const now = new Date(); + const resetDate = new Date(resetTimestamp); + const diff = resetDate.getTime() - now.getTime(); + + if (diff <= 0) return 'Usage reset'; + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + const seconds = Math.floor((diff % (1000 * 60)) / 1000); + + return `Resets in ${hours}h ${minutes}m ${seconds}s`; +} + +export function useUsageStatus(user, userTier) { + const [usageStatus, setUsageStatus] = useState({ + usageCount: null, + usageLimit: null, + resetTime: null, + tier: userTier || null, + error: null, + }); + const [countdown, setCountdown] = useState(''); + + useEffect(() => { + setUsageStatus(prev => ({ ...prev, tier: userTier })); + + if (!user || !user.idToken) { + setUsageStatus({ usageCount: null, usageLimit: null, resetTime: null, tier: userTier, error: 'Not logged in' }); + return; + } + + const fetchStatus = async () => { + try { + const status = await fetchData('/user-status', { + headers: { 'Authorization': `Bearer ${user.idToken}` } + }); + setUsageStatus({ + usageCount: status.usageCount, + usageLimit: status.usageLimit, + resetTime: status.resetTime, + tier: status.tier, + error: null, + }); + } catch (err) { + console.error("Error fetching usage status:", err); + setUsageStatus(prev => ({ ...prev, error: err.message || 'Failed to fetch usage status' })); + } + }; + + fetchStatus(); + }, [user, userTier]); + + useEffect(() => { + if (!usageStatus.resetTime) { + setCountdown(''); + return; + } + + const initialRemaining = formatRemainingTime(usageStatus.resetTime); + setCountdown(initialRemaining); + + if (initialRemaining === 'Usage reset') { + return; + } + + const intervalId = setInterval(() => { + const remaining = formatRemainingTime(usageStatus.resetTime); + setCountdown(remaining); + if (remaining === 'Usage reset') { + clearInterval(intervalId); + } + }, 1000); + + return () => clearInterval(intervalId); + }, [usageStatus.resetTime]); + + return { usageStatus, countdown }; +} diff --git a/src/main.jsx b/src/main.jsx index ef14fef..28be2e4 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -3,18 +3,13 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { GoogleOAuthProvider } from '@react-oauth/google'; import App from './App.jsx'; -// Remove dotenv imports - Vite handles .env files for the frontend -// Access the variable using import.meta.env -// Vite replaces this with the actual value during build/dev const googleClientId = import.meta.env.VITE_GOOGLE_CLIENT_ID; -// Add a check to ensure the variable is loaded if (!googleClientId) { console.error("FATAL ERROR: VITE_GOOGLE_CLIENT_ID is not defined."); console.error("Ensure you have a .env file in the project root (where package.json is)"); console.error("and the variable is named VITE_GOOGLE_CLIENT_ID=YOUR_ID"); - // You might want to render an error message to the user here instead of proceeding } createRoot(document.getElementById('root')).render( @@ -25,4 +20,4 @@ createRoot(document.getElementById('root')).render( -); +); \ No newline at end of file diff --git a/src/services/api.js b/src/services/api.js index 7216e9c..9725dcc 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -1,64 +1,57 @@ - -/** - * Fetches data from a backend endpoint via the /api proxy. - * @param {string} endpoint - The API endpoint *without* the leading /api/ (e.g., 'institutions', 'chat', 'pdf-images/filename.pdf'). - * @param {object} options - Optional fetch options (method, headers, body, etc.). Defaults to GET. - * @returns {Promise} - A promise that resolves with the JSON data or null for empty responses. - * @throws {Error} - Throws an error if the fetch fails or response is not ok. - */ +const USER_STORAGE_KEY = 'collegeTransferUser'; +const API_BASE_URL = '/api'; export async function fetchData(endpoint, options = {}) { - // Construct the full URL, always prepending /api/ - // Ensure no double slashes if endpoint accidentally starts with one const cleanEndpoint = endpoint.startsWith('/') ? endpoint.substring(1) : endpoint; - const url = `/api/${cleanEndpoint}`; // Use relative path for the proxy - + const url = `${API_BASE_URL}/${cleanEndpoint}`; + let token = null; try { - // *** Pass the options object as the second argument to fetch *** - const response = await fetch(url, options); - - if (!response.ok) { - // Try to get error details from response body if available - let errorBody = null; - try { - // Use .text() first in case the error isn't JSON - const text = await response.text(); - if (text) { - errorBody = JSON.parse(text); // Try parsing as JSON - } - } catch (e) { - // Ignore if response body is not JSON or empty - console.warn("Could not parse error response body as JSON:", e); + const storedUser = localStorage.getItem(USER_STORAGE_KEY); + if (storedUser) { + const parsedUser = JSON.parse(storedUser); + if (parsedUser?.idToken) { + token = parsedUser.idToken; } - // Use error from body if available, otherwise use status text - const errorMessage = errorBody?.error || response.statusText || `HTTP error! status: ${response.status}`; - throw new Error(errorMessage); } + } catch (e) { console.error("Error reading user token from localStorage", e); } - // Handle cases where response might be empty (e.g., 204 No Content) - if (response.status === 204) { - return null; // Return null for empty successful responses - } + const headers = { + 'Content-Type': 'application/json', + ...options.headers + }; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } - // Check content type before assuming JSON + try { + const response = await fetch(url, { + ...options, + headers: headers, + }); + if (response.status === 401) { + console.warn(`API request to ${url} resulted in 401 Unauthorized. Token likely expired or invalid.`); + window.dispatchEvent(new CustomEvent('auth-expired')); + throw new Error("Authentication required or session expired."); + } + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(errorData.error || errorData.message || `HTTP error! status: ${response.status}`); + } const contentType = response.headers.get("content-type"); + if (response.status === 204 || !contentType) { + return null; + } if (contentType && contentType.indexOf("application/json") !== -1) { - const data = await response.json(); - return data; + return response.json(); } else { - // Handle non-JSON responses if necessary, or throw an error - console.warn(`Received non-JSON response from ${url}`); - return await response.text(); // Or handle differently + return response.text(); } } catch (error) { - console.error(`Error fetching ${url}:`, error); - // Re-throw the error so the component can handle it - // Ensure it's an actual Error object - if (error instanceof Error) { - throw error; - } else { - throw new Error(String(error)); + console.error(`Fetch error for ${url}:`, error.message); + if (error.message !== "Authentication required or session expired.") { + throw error; } + return null; } } diff --git a/src/utils/formatText.jsx b/src/utils/formatText.jsx new file mode 100644 index 0000000..ab2a656 --- /dev/null +++ b/src/utils/formatText.jsx @@ -0,0 +1,47 @@ +import React from 'react'; + +export function formatText(text) { + if (!text) return ''; + const lines = text.split('\n'); + const processedLines = lines.map(line => { + if (line.trim().startsWith('* ')) { + const starIndex = line.indexOf('*'); + const prefix = line.substring(0, starIndex); + return prefix + '• ' + line.substring(starIndex + 2); + } + return line; + }); + const textWithBullets = processedLines.join('\n'); + const regex = /(\*\*.*?\*\*|`.*?`)/g; + let lastIndex = 0; + const result = []; + let match; + + try { + while ((match = regex.exec(textWithBullets)) !== null) { + if (match.index > lastIndex) { + result.push(textWithBullets.substring(lastIndex, match.index)); + } + + const matchedText = match[0]; + if (matchedText.startsWith('**') && matchedText.endsWith('**')) { + const content = matchedText.length > 4 ? matchedText.slice(2, -2) : ''; + result.push({content}); + } else if (matchedText.startsWith('`') && matchedText.endsWith('`')) { + const content = matchedText.length > 2 ? matchedText.slice(1, -1) : ''; + result.push({content}); + } else { + result.push(matchedText); + } + + lastIndex = regex.lastIndex; + } + if (lastIndex < textWithBullets.length) { + result.push(textWithBullets.substring(lastIndex)); + } + return result.filter(part => part !== null && part !== ''); + } catch (error) { + console.error("Error formatting text:", error, "Original text:", text); + return text; + } +} diff --git a/vite.config.js b/vite.config.js index 3bb44ec..faec4ad 100644 --- a/vite.config.js +++ b/vite.config.js @@ -2,36 +2,26 @@ import { defineConfig, loadEnv } from 'vite' import react from '@vitejs/plugin-react-swc' import dotenv from 'dotenv'; -// Load .env file dotenv.config(); -// https://vite.dev/config/ export default defineConfig(({ mode }) => { - // Load env file based on `mode` in the current working directory. - // Set the third parameter to '' to load all env regardless of the `VITE_` prefix. // eslint-disable-next-line no-undef const env = loadEnv(mode, process.cwd(), ''); return { plugins: [react()], define: { - // Expose environment variables to your client code 'process.env.VITE_GOOGLE_CLIENT_ID': JSON.stringify(env.VITE_GOOGLE_CLIENT_ID), }, server: { - // Configure headers for development server (useful for OAuth popups) headers: { 'Cross-Origin-Opener-Policy': 'same-origin-allow-popups', }, proxy: { - // Proxy API requests to the Flask backend '/api': { - target: 'http://127.0.0.1:5000', // Your Flask backend address - changeOrigin: true, // Recommended for virtual hosted sites - secure: false, // Set to true if your backend uses HTTPS with a valid cert - // --- Add rewrite rule --- - rewrite: (path) => path.replace(/^\/api/, ''), // Remove /api prefix - // --- End rewrite rule --- + target: 'http://127.0.0.1:5000', + changeOrigin: true, + secure: false, }, }, },