From 8cf6ceb63f10781e80464d31d12a99d45b82e0e6 Mon Sep 17 00:00:00 2001 From: Arwa Sharif Date: Sun, 8 Feb 2026 19:18:25 -0500 Subject: [PATCH 1/8] feat(api): add history retrieval endpoints and link data to user_id --- server/main.py | 137 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 43 deletions(-) diff --git a/server/main.py b/server/main.py index e26a74d..321bc00 100644 --- a/server/main.py +++ b/server/main.py @@ -2,15 +2,22 @@ import os from contextlib import asynccontextmanager from dotenv import load_dotenv +from typing import List from utils import log_time_decorator +from models import ( + ChatRequest, + ChatResponse, + ResumeTailorResponse, + ChatSessionResponse, + ResumeSessionResponse, +) from fastapi import FastAPI, HTTPException, Form from fastapi.middleware.cors import CORSMiddleware import firebase_admin from firebase_admin import credentials, firestore from google import genai -from pydantic import BaseModel # --- Define db as None at the top level --- @@ -77,25 +84,8 @@ async def lifespan(app: FastAPI): ) -# --- Pydantic Models --- -class ChatRequest(BaseModel): - message: str - - -class ChatResponse(BaseModel): - response: str - - -class ResumeTailorRequest(BaseModel): - base_resume: str - job_description: str - - -class ResumeTailorResponse(BaseModel): - tailored_resume: str - - # --- API Endpoints --- +# --- Post Chat Endpoint --- @app.post( "/chat", response_model=ChatResponse, summary="Handles Chat with Gemini AI Agent" ) @@ -115,6 +105,7 @@ async def chat_with_agent(request: ChatRequest): try: user_message = request.message + uid = request.user_id if request.user_id else None # Create a single client object client = genai.Client() @@ -127,25 +118,20 @@ async def chat_with_agent(request: ChatRequest): # Extract the AI's reply from the response ai_reply = response.text - # Use a temporary, hardcoded user ID - temp_user_id = "user_abc_123" # In a real app, use actual user IDs - - # Save the conversation to Firestore - chat_data = { - "userId": temp_user_id, - "messages": [ - {"role": "user", "content": user_message}, - {"role": "ai", "content": ai_reply}, - ], - "timestamp": firestore.SERVER_TIMESTAMP, - } - - # Use the db client to add the data to a 'chats' collection. - # Firestore will automatically create the collection if it doesn't exist. - doc_ref = db.collection("chats").add(chat_data) - - # This will print the ID of the new document to your terminal for confirmation. - print(f"Saved chat with ID: {doc_ref[1].id}") + # Only save to DB if we have a real user + if uid and db: + chat_data = { + "user_id": uid, + "messages": [ + {"role": "user", "content": user_message}, + {"role": "ai", "content": ai_reply}, + ], + "timestamp": firestore.SERVER_TIMESTAMP, + } + # Capture the return value + doc_ref = db.collection("chats").add(chat_data) + + print(f"Saved chat with ID: {doc_ref[1].id}") # 4. Return the model's response return ChatResponse(response=response.text) @@ -153,18 +139,20 @@ async def chat_with_agent(request: ChatRequest): except Exception as e: print(f"An error occured: {e}") raise HTTPException( - status_code=500, detail="Failed to get reponst from AI model" + status_code=500, detail="Failed to get response from AI model" ) -# --- Resumes Endpoint --- +# --- Post Resumes Endpoint --- @app.post( "/resumes", response_model=ResumeTailorResponse, summary="Generates a tailored resume", ) @log_time_decorator -async def generate_resume(base_resume: str = Form(), job_description: str = Form()): +async def generate_resume( + base_resume: str = Form(), job_description: str = Form(), user_id: str = Form() +): """ Endpoint to tailor a resume for a specific job description using Gemini AI. This endpoint accepts form data, making it easy to paste multi-line text. @@ -209,12 +197,12 @@ async def generate_resume(base_resume: str = Form(), job_description: str = Form tailored_resume_obj = ResumeTailorResponse(tailored_resume=response.text) resume_record = { - "userId": "demo_user_123", + "user_id": user_id, "originalResume": base_resume, "jobDescription": job_description, "tailoredResume": tailored_resume_obj.tailored_resume, "createdAt": datetime.datetime.now(datetime.timezone.utc), - "meta": {"modelUsed": "gemini-1.5-flash", "processingTimeMs": 1200}, + "meta": {"modelUsed": "gemini-2.5-flash", "processingTimeMs": 1200}, } # Save to Firestore @@ -229,3 +217,66 @@ async def generate_resume(base_resume: str = Form(), job_description: str = Form except Exception as e: print(f"An error occurred: {e}") raise HTTPException(status_code=500, detail="Failed to generate resume.") + + +# --- Read Chats Endpoint --- +@app.get("/chats/{user_id}", response_model=List[ChatSessionResponse]) +async def read_chats(user_id: str): + try: + # Query the collection for docs belonging to this user + # Ew also order them by timestamp so the newest (or oldest) show up correctly + query = ( + db.collection("chats") + .where("user_id", "==", user_id) + .order_by("timestamp", direction=firestore.Query.DESCENDING) + ) + docs = query.stream() + + history = [] + + for doc in docs: + data = doc.to_dict() + history.append( + { + "id": doc.id, + "messages": data.get("messages", []), + # Handle potential missing timestamps gracefully + "timestamp": data.get("timestamp") or datetime.datetime.now(), + } + ) + + return history + + except Exception as e: + print(f"Error fetching history: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# --- Read Resume Endpoint --- +@app.get("/resumes/{user_id}", response_model=List[ResumeSessionResponse]) +async def read_resumes(user_id: str): + try: + query = ( + db.collection("tailored_resumes") + .where("user_id", "==", user_id) + .order_by("createdAt", direction=firestore.Query.DESCENDING) + ) + docs = query.stream() + history = [] + + for doc in docs: + data = doc.to_dict() + history.append( + { + "id": doc.id, + "originalResume": data.get("originalResume", []), + "tailoredResume": data.get("tailoredResume", []), + "createdAt": data.get("createdAt") or datetime.datetime.now(), + } + ) + + return history + + except Exception as e: + print(f"Error fetching history: {e}") + raise HTTPException(status_code=500, detail=str(e)) From 0fec68006d6888e2f4c7599b8ba1b7587e480d95 Mon Sep 17 00:00:00 2001 From: Arwa Sharif Date: Sun, 8 Feb 2026 19:19:03 -0500 Subject: [PATCH 2/8] refactor(api): move pydantic models to dedicated models.py --- server/models.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 server/models.py diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..74bdfd2 --- /dev/null +++ b/server/models.py @@ -0,0 +1,34 @@ +from pydantic import BaseModel +from typing import List +import datetime + + +class ChatRequest(BaseModel): + message: str + user_id: str + + +class ChatResponse(BaseModel): + response: str + + +class ResumeTailorResponse(BaseModel): + tailored_resume: str + + +class MessageModel(BaseModel): + role: str + content: str + + +class ChatSessionResponse(BaseModel): + id: str + messages: List[MessageModel] + timestamp: datetime.datetime + + +class ResumeSessionResponse(BaseModel): + id: str + originalResume: str + tailoredResume: str + createdAt: datetime.datetime From 2023ea65983fa8bb210751811cc00a4fd1188855 Mon Sep 17 00:00:00 2001 From: Arwa Sharif Date: Sun, 8 Feb 2026 19:23:42 -0500 Subject: [PATCH 3/8] feat(fe): align API keys to user_id and implement unauthenticated UI guard --- client/components/Chat.tsx | 60 ++++++++++++++++-------- client/components/ResumeBuilder.tsx | 71 +++++++++++++++++++---------- 2 files changed, 87 insertions(+), 44 deletions(-) diff --git a/client/components/Chat.tsx b/client/components/Chat.tsx index 66a90d8..d85558e 100644 --- a/client/components/Chat.tsx +++ b/client/components/Chat.tsx @@ -1,15 +1,17 @@ "use client"; import { useState } from "react"; import ReactMarkdown from "react-markdown"; +import { useAuth } from "@/context/AuthContext"; interface Message { role: "user" | "ai"; content: string; } -export default function Chat(){ +export default function Chat() { + const { user } = useAuth(); const [input, setInput] = useState(""); - const [messages, setMessages] = useState([]) + const [messages, setMessages] = useState([]); const [isLoading, setIsLoading] = useState(false); const handleSend = async () => { @@ -19,49 +21,69 @@ export default function Chat(){ setMessages((prev) => [...prev, userMessage]); setInput(""); setIsLoading(true); - + let res; try { - const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chat`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: userMessage.content }), - }); + if (!user) { + res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: userMessage.content, + userId: "None", + }), + }); + } else { + res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/chat`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message: userMessage.content, + userId: user?.uid, + }), + }); + } if (!res.ok) throw new Error("Failed to fetch response"); const data = await res.json(); - const aiMessage: Message = { role: "ai", content: data.reply || data.response }; + const aiMessage: Message = { + role: "ai", + content: data.reply || data.response, + }; setMessages((prev) => [...prev, aiMessage]); } catch (error) { console.error(error); setMessages((prev) => [ ...prev, - { role: "ai", content: "Sorry, I encountered an error. Please try again." }, + { + role: "ai", + content: "Sorry, I encountered an error. Please try again.", + }, ]); } finally { setIsLoading(false); } }; - - return( -
- + return ( +
{/* Messages Area */}
{messages.length === 0 && (

Hi! I'm ApplyAI.

-

Ask me how to improve your resume or prepare for an interview.

+

+ Ask me how to improve your resume or prepare for an interview. +

)} - + {messages.map((msg, idx) => (
@@ -74,7 +96,7 @@ export default function Chat(){ )}
))} - + {isLoading && (
Thinking... @@ -102,4 +124,4 @@ export default function Chat(){
); -} \ No newline at end of file +} diff --git a/client/components/ResumeBuilder.tsx b/client/components/ResumeBuilder.tsx index d3503de..675e605 100644 --- a/client/components/ResumeBuilder.tsx +++ b/client/components/ResumeBuilder.tsx @@ -1,13 +1,15 @@ "use client"; import { useState } from "react"; import type { MouseEvent } from "react"; -import ResumeDisplay from "./ResumeDisplay" +import { useAuth } from "@/context/AuthContext"; +import ResumeDisplay from "./ResumeDisplay"; // The URL for the deployed backend. const API_URL = process.env.NEXT_PUBLIC_API_URL; export default function ResumeBuilder() { // State for the user's input + const { user } = useAuth(); const [resume, setResume] = useState(""); const [jobDescription, setJobDescription] = useState(""); @@ -16,9 +18,8 @@ export default function ResumeBuilder() { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - - - const handleSubmit = async (e : MouseEvent) => { + const handleSubmit = async (e: MouseEvent) => { + if (!user) return; e.preventDefault(); setIsLoading(true); setError(null); @@ -28,8 +29,9 @@ export default function ResumeBuilder() { const formData = new FormData(); formData.append("base_resume", resume); formData.append("job_description", jobDescription); + formData.append("userId", user.uid); - try{ + try { // Make the API call to /resumes endpoint const response = await fetch(`${API_URL}/resumes`, { method: "POST", @@ -45,7 +47,7 @@ export default function ResumeBuilder() { // Update state with the AI's response setTailoredResume(data.tailored_resume); - } catch (err){ + } catch (err) { console.error("Fetch error:", err); if (err instanceof Error) { setError(err.message); @@ -55,17 +57,18 @@ export default function ResumeBuilder() { } finally { // Whether it worked or failed, we're done loading setIsLoading(false); - }; - + } }; return (
-
{/* Resume Text Area */}
-