RubaKhan242 commited on
Commit
e663c0c
·
1 Parent(s): 31fe301

Deploy FastAPI backend for AI recruitment system

Browse files

- Add Dockerfile for HF Spaces (port 7860)
- Add FastAPI app with jobs, applications, hr routes
- Add AI resume analysis with OpenAI gpt-4o-mini
- Add automated email notifications (selection/rejection)
- Fix env var loading to use system environment (HF Spaces secrets)

.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.pyo
4
+ .env
5
+ *.log
6
+ .venv/
7
+ venv/
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install system dependencies for pdfplumber
6
+ RUN apt-get update && apt-get install -y \
7
+ libpoppler-cpp-dev \
8
+ poppler-utils \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY . .
15
+
16
+ # Hugging Face Spaces uses port 7860
17
+ EXPOSE 7860
18
+
19
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,21 @@
1
  ---
2
- title: AIproject
3
- emoji:
4
  colorFrom: purple
5
  colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SmartHire AI Backend
3
+ emoji: 🤖
4
  colorFrom: purple
5
  colorTo: yellow
6
  sdk: docker
7
  pinned: false
8
  ---
9
 
10
+ # SmartHire AI FastAPI Backend
11
+
12
+ AI-powered recruitment system backend. Handles resume screening, ATS scoring, and automated email notifications.
13
+
14
+ ## Endpoints
15
+ - `GET /` — Health check
16
+ - `POST /api/jobs/create` — Create job posting
17
+ - `GET /api/jobs/{job_id}` — Get job details
18
+ - `GET /api/jobs/hr/{hr_id}` — Get HR's jobs
19
+ - `POST /api/applications/submit` — Submit application with resume PDF
20
+ - `GET /api/applications/{job_id}` — Get applications for a job
21
+ - `POST /api/hr/register` — Register HR user
agent/__init__.py ADDED
File without changes
agent/resume_agent.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import json
4
+ import logging
5
+
6
+ import pdfplumber
7
+ from openai import AsyncOpenAI
8
+ from dotenv import load_dotenv
9
+
10
+ load_dotenv()
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ client = AsyncOpenAI(api_key=os.getenv("OPENAI_API_KEY"))
15
+
16
+
17
+ def extract_text_from_pdf(pdf_bytes: bytes) -> str:
18
+ text = ""
19
+ try:
20
+ with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
21
+ for page in pdf.pages:
22
+ page_text = page.extract_text()
23
+ if page_text:
24
+ text += page_text + "\n"
25
+ except Exception as exc:
26
+ logger.error("Failed to extract text from PDF: %s", exc)
27
+ raise ValueError(f"Could not extract text from PDF: {exc}") from exc
28
+ return text.strip()
29
+
30
+
31
+ async def analyze_resume(
32
+ pdf_bytes: bytes,
33
+ job_title: str,
34
+ job_description: str,
35
+ required_skills: list,
36
+ experience_years: int,
37
+ company_name: str
38
+ ) -> dict:
39
+ resume_text = extract_text_from_pdf(pdf_bytes)
40
+ if not resume_text:
41
+ raise ValueError("Could not extract text from PDF – the file may be image-only or corrupted.")
42
+
43
+ skills_str = ", ".join(required_skills)
44
+
45
+ system_prompt = """You are an expert AI recruitment screener. Analyze the resume against the job requirements and return ONLY a valid JSON object — no markdown, no extra text.
46
+
47
+ JSON structure:
48
+ {
49
+ "status": "selected" or "rejected",
50
+ "ats_score": <integer 0-100>,
51
+ "candidate_name": "<full name from resume>",
52
+ "missing_skills": ["skill1", "skill2"],
53
+ "ai_feedback": "<2-3 sentence professional feedback>",
54
+ "skill_recommendations": [
55
+ {
56
+ "skill": "<missing skill>",
57
+ "why_it_matters": "<why this skill is important for the role>",
58
+ "resource_link": "<free learning URL from Coursera/YouTube/freeCodeCamp/official docs>",
59
+ "time_to_learn": "<realistic duration>"
60
+ }
61
+ ]
62
+ }
63
+
64
+ Scoring rules:
65
+ - ATS score 0-100 based on: skill match (50%), experience (25%), education (15%), resume quality (10%)
66
+ - Select if ats_score >= 70, reject otherwise
67
+ - skill_recommendations only for missing skills (empty array if selected with all skills)"""
68
+
69
+ user_prompt = f"""JOB: {job_title} at {company_name}
70
+ REQUIRED SKILLS: {skills_str}
71
+ MIN EXPERIENCE: {experience_years} years
72
+ DESCRIPTION: {job_description[:800]}
73
+
74
+ RESUME:
75
+ {resume_text[:3000]}
76
+
77
+ Analyze and return JSON only."""
78
+
79
+ logger.info("Starting resume analysis for: %s at %s", job_title, company_name)
80
+
81
+ response = await client.chat.completions.create(
82
+ model="gpt-4o-mini",
83
+ messages=[
84
+ {"role": "system", "content": system_prompt},
85
+ {"role": "user", "content": user_prompt},
86
+ ],
87
+ temperature=0.2,
88
+ max_tokens=1200,
89
+ response_format={"type": "json_object"},
90
+ )
91
+
92
+ output = response.choices[0].message.content or "{}"
93
+
94
+ try:
95
+ parsed = json.loads(output)
96
+ except json.JSONDecodeError as exc:
97
+ logger.error("Invalid JSON from OpenAI: %s", output[:300])
98
+ raise ValueError(f"Invalid JSON response: {exc}") from exc
99
+
100
+ logger.info("Analysis done — status: %s, score: %s", parsed.get("status"), parsed.get("ats_score"))
101
+ return parsed
agent/tools.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ from agents import function_tool
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ @function_tool
9
+ async def extract_resume_info(resume_text: str) -> str:
10
+ """
11
+ Extract structured information from raw resume text.
12
+ Returns a JSON string with candidate name, skills, experience years,
13
+ education level, and certifications.
14
+ """
15
+ result = {
16
+ "candidate_name": "Unknown",
17
+ "skills": [],
18
+ "experience_years": 0,
19
+ "education": "Unknown",
20
+ "certifications": []
21
+ }
22
+ logger.debug("extract_resume_info called with %d chars of resume text", len(resume_text))
23
+ return json.dumps(result)
24
+
25
+
26
+ @function_tool
27
+ async def match_skills(resume_skills: str, required_skills: str) -> str:
28
+ """
29
+ Compare resume skills against required job skills.
30
+
31
+ Args:
32
+ resume_skills: comma-separated list of skills found in the resume
33
+ required_skills: comma-separated list of skills required for the job
34
+
35
+ Returns:
36
+ JSON string with matched_skills, missing_skills, and match_percentage
37
+ """
38
+ resume_set = {s.strip().lower() for s in resume_skills.split(",") if s.strip()}
39
+ required_set = {s.strip().lower() for s in required_skills.split(",") if s.strip()}
40
+
41
+ if not required_set:
42
+ return json.dumps({
43
+ "matched_skills": [],
44
+ "missing_skills": [],
45
+ "match_percentage": 0.0
46
+ })
47
+
48
+ matched = sorted(resume_set & required_set)
49
+ missing = sorted(required_set - resume_set)
50
+ match_percentage = round((len(matched) / len(required_set)) * 100, 2)
51
+
52
+ result = {
53
+ "matched_skills": matched,
54
+ "missing_skills": missing,
55
+ "match_percentage": match_percentage
56
+ }
57
+ logger.debug("match_skills: %d/%d matched (%.1f%%)", len(matched), len(required_set), match_percentage)
58
+ return json.dumps(result)
59
+
60
+
61
+ @function_tool
62
+ async def calculate_ats_score(
63
+ skill_match_percentage: float,
64
+ experience_years_candidate: int,
65
+ experience_years_required: int,
66
+ education_level: str,
67
+ resume_quality: str
68
+ ) -> str:
69
+ """
70
+ Calculate ATS score (0–100) based on multiple criteria.
71
+ """
72
+ skill_match_percentage = max(0.0, min(100.0, float(skill_match_percentage)))
73
+ skill_score = round(50 * skill_match_percentage / 100, 2)
74
+
75
+ if experience_years_candidate >= experience_years_required:
76
+ experience_score = 25
77
+ elif experience_years_required > 0 and experience_years_candidate >= experience_years_required / 2:
78
+ experience_score = 12
79
+ else:
80
+ experience_score = 0
81
+
82
+ education_map = {
83
+ "phd": 15, "masters": 15, "master": 15,
84
+ "bachelors": 12, "bachelor": 12, "diploma": 8,
85
+ }
86
+ education_score = education_map.get(education_level.strip().lower(), 4)
87
+
88
+ quality_map = {"good": 10, "average": 6, "poor": 2}
89
+ quality_score = quality_map.get(resume_quality.strip().lower(), 6)
90
+
91
+ ats_score = round(skill_score + experience_score + education_score + quality_score, 2)
92
+ ats_score = max(0, min(100, ats_score))
93
+
94
+ result = {
95
+ "ats_score": ats_score,
96
+ "breakdown": {
97
+ "skill_score": skill_score,
98
+ "experience_score": experience_score,
99
+ "education_score": education_score,
100
+ "quality_score": quality_score
101
+ }
102
+ }
103
+ logger.debug("calculate_ats_score: total=%.2f", ats_score)
104
+ return json.dumps(result)
105
+
106
+
107
+ @function_tool
108
+ async def make_decision(
109
+ ats_score: int,
110
+ candidate_name: str,
111
+ missing_skills: str,
112
+ job_title: str
113
+ ) -> str:
114
+ """
115
+ Make a hiring decision based on ATS score.
116
+ """
117
+ missing_list = [s.strip() for s in missing_skills.split(",") if s.strip()]
118
+
119
+ if ats_score >= 70:
120
+ status = "selected"
121
+ recommendation_message = (
122
+ f"Congratulations, {candidate_name}! Your profile is a strong match for the "
123
+ f"{job_title} position. Our recruitment team will be in touch with next steps shortly."
124
+ )
125
+ else:
126
+ status = "rejected"
127
+ if missing_list:
128
+ skills_str = ", ".join(missing_list)
129
+ recommendation_message = (
130
+ f"Thank you for applying, {candidate_name}. Unfortunately your profile did not meet "
131
+ f"the minimum requirements for the {job_title} role at this time. "
132
+ f"We recommend strengthening the following areas: {skills_str}."
133
+ )
134
+ else:
135
+ recommendation_message = (
136
+ f"Thank you for applying, {candidate_name}. Unfortunately your profile did not meet "
137
+ f"the minimum requirements for the {job_title} role at this time."
138
+ )
139
+
140
+ result = {
141
+ "status": status,
142
+ "ats_score": ats_score,
143
+ "missing_skills": missing_list,
144
+ "recommendation_message": recommendation_message
145
+ }
146
+ logger.debug("make_decision: %s (score=%d)", status, ats_score)
147
+ return json.dumps(result)
email_service.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import smtplib
2
+ import logging
3
+ import os
4
+ from email.mime.multipart import MIMEMultipart
5
+ from email.mime.text import MIMEText
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com")
11
+ SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
12
+ SMTP_EMAIL = os.getenv("SMTP_EMAIL")
13
+ SMTP_APP_PASSWORD = os.getenv("SMTP_APP_PASSWORD")
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _send_email(to_email: str, subject: str, html_body: str) -> bool:
19
+ """Core email sending function with one retry on failure."""
20
+ for attempt in range(2):
21
+ try:
22
+ msg = MIMEMultipart("alternative")
23
+ msg["Subject"] = subject
24
+ msg["From"] = SMTP_EMAIL
25
+ msg["To"] = to_email
26
+
27
+ part = MIMEText(html_body, "html")
28
+ msg.attach(part)
29
+
30
+ with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server:
31
+ server.ehlo()
32
+ server.starttls()
33
+ server.login(SMTP_EMAIL, SMTP_APP_PASSWORD)
34
+ server.sendmail(SMTP_EMAIL, to_email, msg.as_string())
35
+
36
+ logger.info(f"Email sent successfully to {to_email} | Subject: {subject}")
37
+ return True
38
+
39
+ except smtplib.SMTPException as e:
40
+ logger.error(f"SMTP error on attempt {attempt + 1} sending to {to_email}: {e}")
41
+ if attempt == 1:
42
+ return False
43
+ except Exception as e:
44
+ logger.error(f"Unexpected error on attempt {attempt + 1} sending to {to_email}: {e}")
45
+ if attempt == 1:
46
+ return False
47
+
48
+ return False
49
+
50
+
51
+ def send_rejection_email(
52
+ candidate_name: str,
53
+ candidate_email: str,
54
+ job_title: str,
55
+ company_name: str,
56
+ ats_score: float,
57
+ missing_skills_with_recommendations: list
58
+ ) -> bool:
59
+ subject = f"Your Application for {job_title} at {company_name} – Update"
60
+
61
+ skills_rows_html = ""
62
+ if missing_skills_with_recommendations:
63
+ for item in missing_skills_with_recommendations:
64
+ skill = item.get("skill", "")
65
+ why = item.get("why_it_matters", "")
66
+ link = item.get("resource_link", "#")
67
+ time_to_learn = item.get("time_to_learn", "")
68
+ skills_rows_html += f"""
69
+ <tr>
70
+ <td style="padding:10px 12px; border-bottom:1px solid #f0f0f0; color:#333; font-weight:600;">{skill}</td>
71
+ <td style="padding:10px 12px; border-bottom:1px solid #f0f0f0; color:#555;">{why}</td>
72
+ <td style="padding:10px 12px; border-bottom:1px solid #f0f0f0;">
73
+ <a href="{link}" style="color:#4F46E5; text-decoration:none; font-weight:500;">Learn Now &rarr;</a>
74
+ </td>
75
+ <td style="padding:10px 12px; border-bottom:1px solid #f0f0f0; color:#888; text-align:center;">{time_to_learn}</td>
76
+ </tr>"""
77
+
78
+ skills_table_html = ""
79
+ if skills_rows_html:
80
+ skills_table_html = f"""
81
+ <div style="margin-top:28px;">
82
+ <h3 style="font-size:16px; color:#1a1a2e; margin-bottom:12px; font-weight:700;">
83
+ Recommended Skills to Strengthen Your Profile
84
+ </h3>
85
+ <table width="100%" cellpadding="0" cellspacing="0" style="border-collapse:collapse; background:#fafafa; border-radius:8px; overflow:hidden;">
86
+ <thead>
87
+ <tr style="background:#4F46E5;">
88
+ <th style="padding:10px 12px; color:#fff; text-align:left; font-size:13px;">Skill</th>
89
+ <th style="padding:10px 12px; color:#fff; text-align:left; font-size:13px;">Why It Matters</th>
90
+ <th style="padding:10px 12px; color:#fff; text-align:left; font-size:13px;">Resource</th>
91
+ <th style="padding:10px 12px; color:#fff; text-align:center; font-size:13px;">Time to Learn</th>
92
+ </tr>
93
+ </thead>
94
+ <tbody>
95
+ {skills_rows_html}
96
+ </tbody>
97
+ </table>
98
+ </div>"""
99
+
100
+ html_body = f"""<!DOCTYPE html>
101
+ <html lang="en">
102
+ <head>
103
+ <meta charset="UTF-8">
104
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
105
+ <title>Application Update</title>
106
+ </head>
107
+ <body style="margin:0; padding:0; background-color:#f4f6fb; font-family:'Segoe UI', Arial, sans-serif;">
108
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6fb; padding:40px 0;">
109
+ <tr>
110
+ <td align="center">
111
+ <table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
112
+ <tr>
113
+ <td style="background:linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding:36px 40px; text-align:center;">
114
+ <h1 style="margin:0; color:#ffffff; font-size:24px; font-weight:700; letter-spacing:0.5px;">{company_name}</h1>
115
+ <p style="margin:8px 0 0; color:rgba(255,255,255,0.85); font-size:14px;">Recruitment Team</p>
116
+ </td>
117
+ </tr>
118
+ <tr>
119
+ <td style="padding:40px;">
120
+ <p style="font-size:16px; color:#333; margin:0 0 16px;">Dear <strong>{candidate_name}</strong>,</p>
121
+ <p style="font-size:15px; color:#555; line-height:1.7; margin:0 0 16px;">
122
+ Thank you for taking the time to apply for the <strong>{job_title}</strong> position at <strong>{company_name}</strong>.
123
+ </p>
124
+ <p style="font-size:15px; color:#555; line-height:1.7; margin:0 0 24px;">
125
+ After a thorough review, we regret to inform you that we will not be moving forward with your application at this time.
126
+ </p>
127
+ <div style="background:#fef3f2; border:1px solid #fecaca; border-radius:10px; padding:20px 24px; margin-bottom:24px; text-align:center;">
128
+ <p style="margin:0 0 6px; font-size:13px; color:#888; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Your ATS Match Score</p>
129
+ <p style="margin:0; font-size:42px; font-weight:800; color:#e53e3e;">{int(ats_score)}<span style="font-size:20px; font-weight:500; color:#999;">/ 100</span></p>
130
+ <p style="margin:6px 0 0; font-size:13px; color:#999;">A score of 70 or above is required to proceed</p>
131
+ </div>
132
+ {skills_table_html}
133
+ <p style="font-size:15px; color:#555; line-height:1.7; margin:28px 0 32px;">
134
+ We strongly encourage you to work on the skills listed above and apply again in the future.
135
+ </p>
136
+ <p style="font-size:15px; color:#333; margin:0;">
137
+ Warm regards,<br>
138
+ <strong>The Recruitment Team</strong><br>
139
+ <span style="color:#888; font-size:13px;">{company_name}</span>
140
+ </p>
141
+ </td>
142
+ </tr>
143
+ <tr>
144
+ <td style="background:#f8f9fc; padding:20px 40px; text-align:center; border-top:1px solid #eee;">
145
+ <p style="margin:0; font-size:12px; color:#aaa;">This is an automated message. Please do not reply directly to this email.</p>
146
+ </td>
147
+ </tr>
148
+ </table>
149
+ </td>
150
+ </tr>
151
+ </table>
152
+ </body>
153
+ </html>"""
154
+
155
+ return _send_email(candidate_email, subject, html_body)
156
+
157
+
158
+ def send_selection_email(
159
+ candidate_name: str,
160
+ candidate_email: str,
161
+ job_title: str,
162
+ company_name: str,
163
+ ats_score: float
164
+ ) -> bool:
165
+ subject = f"Congratulations! You've Been Selected – {job_title} at {company_name}"
166
+
167
+ html_body = f"""<!DOCTYPE html>
168
+ <html lang="en">
169
+ <head>
170
+ <meta charset="UTF-8">
171
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
172
+ <title>Congratulations!</title>
173
+ </head>
174
+ <body style="margin:0; padding:0; background-color:#f4f6fb; font-family:'Segoe UI', Arial, sans-serif;">
175
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6fb; padding:40px 0;">
176
+ <tr>
177
+ <td align="center">
178
+ <table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
179
+ <tr>
180
+ <td style="background:linear-gradient(135deg, #11998e 0%, #38ef7d 100%); padding:36px 40px; text-align:center;">
181
+ <div style="font-size:48px; margin-bottom:12px;">&#127881;</div>
182
+ <h1 style="margin:0; color:#ffffff; font-size:26px; font-weight:700;">Congratulations!</h1>
183
+ <p style="margin:8px 0 0; color:rgba(255,255,255,0.9); font-size:15px;">You have been selected!</p>
184
+ </td>
185
+ </tr>
186
+ <tr>
187
+ <td style="padding:40px;">
188
+ <p style="font-size:16px; color:#333; margin:0 0 16px;">Dear <strong>{candidate_name}</strong>,</p>
189
+ <p style="font-size:15px; color:#555; line-height:1.7; margin:0 0 16px;">
190
+ We are thrilled to inform you that you have been <strong style="color:#11998e;">selected</strong> for the
191
+ <strong>{job_title}</strong> position at <strong>{company_name}</strong>!
192
+ </p>
193
+ <div style="background:#f0fdf4; border:1px solid #86efac; border-radius:10px; padding:20px 24px; margin-bottom:28px; text-align:center;">
194
+ <p style="margin:0 0 6px; font-size:13px; color:#888; text-transform:uppercase; letter-spacing:1px; font-weight:600;">Your ATS Match Score</p>
195
+ <p style="margin:0; font-size:42px; font-weight:800; color:#16a34a;">{int(ats_score)}<span style="font-size:20px; font-weight:500; color:#999;">/ 100</span></p>
196
+ </div>
197
+ <div style="background:#f8f9fc; border-left:4px solid #11998e; border-radius:0 8px 8px 0; padding:20px 24px; margin-bottom:28px;">
198
+ <h3 style="margin:0 0 12px; font-size:15px; color:#1a1a2e; font-weight:700;">What Happens Next?</h3>
199
+ <ul style="margin:0; padding-left:20px; color:#555; font-size:14px; line-height:2;">
200
+ <li>Our HR team will review your application in detail</li>
201
+ <li>You will be contacted within <strong>2–3 business days</strong> to schedule an interview</li>
202
+ <li>Please ensure your contact details are up to date</li>
203
+ </ul>
204
+ </div>
205
+ <p style="font-size:15px; color:#333; margin:0;">
206
+ Best regards,<br>
207
+ <strong>The Recruitment Team</strong><br>
208
+ <span style="color:#888; font-size:13px;">{company_name}</span>
209
+ </p>
210
+ </td>
211
+ </tr>
212
+ <tr>
213
+ <td style="background:#f8f9fc; padding:20px 40px; text-align:center; border-top:1px solid #eee;">
214
+ <p style="margin:0; font-size:12px; color:#aaa;">This is an automated message. Please do not reply directly to this email.</p>
215
+ </td>
216
+ </tr>
217
+ </table>
218
+ </td>
219
+ </tr>
220
+ </table>
221
+ </body>
222
+ </html>"""
223
+
224
+ return _send_email(candidate_email, subject, html_body)
225
+
226
+
227
+ def send_hr_notification_email(
228
+ hr_email: str,
229
+ candidate_name: str,
230
+ candidate_email: str,
231
+ job_title: str,
232
+ company_name: str,
233
+ ats_score: float,
234
+ applied_at: str
235
+ ) -> bool:
236
+ subject = f"New Application Received – {job_title} | ATS Score: {int(ats_score)}"
237
+
238
+ score_color = "#16a34a" if ats_score >= 70 else "#e53e3e"
239
+ status_label = "SELECTED" if ats_score >= 70 else "REJECTED"
240
+ status_bg = "#f0fdf4" if ats_score >= 70 else "#fef3f2"
241
+ status_border = "#86efac" if ats_score >= 70 else "#fecaca"
242
+
243
+ html_body = f"""<!DOCTYPE html>
244
+ <html lang="en">
245
+ <head>
246
+ <meta charset="UTF-8">
247
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
248
+ <title>New Application Notification</title>
249
+ </head>
250
+ <body style="margin:0; padding:0; background-color:#f4f6fb; font-family:'Segoe UI', Arial, sans-serif;">
251
+ <table width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f6fb; padding:40px 0;">
252
+ <tr>
253
+ <td align="center">
254
+ <table width="620" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 20px rgba(0,0,0,0.08);">
255
+ <tr>
256
+ <td style="background:linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); padding:30px 40px; text-align:center;">
257
+ <h1 style="margin:0; color:#ffffff; font-size:20px; font-weight:700;">&#128203; New Application Alert</h1>
258
+ <p style="margin:8px 0 0; color:rgba(255,255,255,0.7); font-size:13px;">{company_name} – HR Dashboard</p>
259
+ </td>
260
+ </tr>
261
+ <tr>
262
+ <td style="padding:36px 40px;">
263
+ <div style="background:#f8f9fc; border-radius:10px; padding:24px; margin-bottom:24px;">
264
+ <h3 style="margin:0 0 16px; font-size:15px; color:#1a1a2e; font-weight:700;">Candidate Details</h3>
265
+ <table width="100%" cellpadding="0" cellspacing="0">
266
+ <tr>
267
+ <td style="padding:8px 0; font-size:14px; color:#888; width:160px;">Full Name</td>
268
+ <td style="padding:8px 0; font-size:14px; color:#333; font-weight:600;">{candidate_name}</td>
269
+ </tr>
270
+ <tr>
271
+ <td style="padding:8px 0; font-size:14px; color:#888;">Email Address</td>
272
+ <td style="padding:8px 0; font-size:14px;">
273
+ <a href="mailto:{candidate_email}" style="color:#4F46E5; text-decoration:none;">{candidate_email}</a>
274
+ </td>
275
+ </tr>
276
+ <tr>
277
+ <td style="padding:8px 0; font-size:14px; color:#888;">Applied For</td>
278
+ <td style="padding:8px 0; font-size:14px; color:#333; font-weight:600;">{job_title}</td>
279
+ </tr>
280
+ <tr>
281
+ <td style="padding:8px 0; font-size:14px; color:#888;">Applied At</td>
282
+ <td style="padding:8px 0; font-size:14px; color:#333;">{applied_at}</td>
283
+ </tr>
284
+ </table>
285
+ </div>
286
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px;">
287
+ <tr>
288
+ <td width="48%" style="vertical-align:top; padding-right:8px;">
289
+ <div style="background:#f8f9fc; border-radius:10px; padding:20px; text-align:center;">
290
+ <p style="margin:0 0 6px; font-size:12px; color:#888; text-transform:uppercase; font-weight:600;">ATS Score</p>
291
+ <p style="margin:0; font-size:38px; font-weight:800; color:{score_color};">{int(ats_score)}<span style="font-size:16px; font-weight:400; color:#999;">/100</span></p>
292
+ </div>
293
+ </td>
294
+ <td width="4%"></td>
295
+ <td width="48%" style="vertical-align:top; padding-left:8px;">
296
+ <div style="background:{status_bg}; border:1px solid {status_border}; border-radius:10px; padding:20px; text-align:center;">
297
+ <p style="margin:0 0 6px; font-size:12px; color:#888; text-transform:uppercase; font-weight:600;">Decision</p>
298
+ <p style="margin:0; font-size:22px; font-weight:800; color:{score_color};">{status_label}</p>
299
+ </div>
300
+ </td>
301
+ </tr>
302
+ </table>
303
+ <p style="font-size:14px; color:#888; line-height:1.6; margin:0;">
304
+ The candidate has been automatically notified. Log in to your HR dashboard to view the full AI analysis report.
305
+ </p>
306
+ </td>
307
+ </tr>
308
+ <tr>
309
+ <td style="background:#f8f9fc; padding:20px 40px; text-align:center; border-top:1px solid #eee;">
310
+ <p style="margin:0; font-size:12px; color:#aaa;">This notification was generated automatically by the {company_name} AI Recruitment System.</p>
311
+ </td>
312
+ </tr>
313
+ </table>
314
+ </td>
315
+ </tr>
316
+ </table>
317
+ </body>
318
+ </html>"""
319
+
320
+ return _send_email(hr_email, subject, html_body)
main.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from routes.jobs import router as jobs_router
5
+ from routes.applications import router as applications_router
6
+ from routes.hr import router as hr_router
7
+
8
+ # ---------------------------------------------------------------------------
9
+ # Logging configuration
10
+ # ---------------------------------------------------------------------------
11
+ logging.basicConfig(
12
+ level=logging.INFO,
13
+ format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
14
+ datefmt="%Y-%m-%d %H:%M:%S",
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # FastAPI application
21
+ # ---------------------------------------------------------------------------
22
+ app = FastAPI(
23
+ title="AI Recruitment System",
24
+ description="Automated resume screening and candidate evaluation powered by AI.",
25
+ version="1.0.0",
26
+ )
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # CORS middleware
30
+ # ---------------------------------------------------------------------------
31
+ import os
32
+ ALLOWED_ORIGINS = [
33
+ "http://localhost:3000",
34
+ os.getenv("FRONTEND_URL", ""),
35
+ ]
36
+ ALLOWED_ORIGINS = [o for o in ALLOWED_ORIGINS if o]
37
+
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=ALLOWED_ORIGINS,
41
+ allow_origin_regex=r"https://.*\.vercel\.app",
42
+ allow_credentials=True,
43
+ allow_methods=["*"],
44
+ allow_headers=["*"],
45
+ )
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Routers
49
+ # ---------------------------------------------------------------------------
50
+ app.include_router(jobs_router, prefix="/api/jobs", tags=["jobs"])
51
+ app.include_router(applications_router, prefix="/api/applications", tags=["applications"])
52
+ app.include_router(hr_router, prefix="/api/hr", tags=["hr"])
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Health check
57
+ # ---------------------------------------------------------------------------
58
+ @app.get("/", tags=["health"])
59
+ def root():
60
+ return {"message": "AI Recruitment System API", "status": "running", "version": "1.0.0"}
61
+
62
+
63
+ @app.get("/health", tags=["health"])
64
+ def health_check():
65
+ return {"status": "healthy"}
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn==0.30.6
3
+ python-dotenv==1.0.1
4
+ supabase==2.9.0
5
+ openai==1.51.0
6
+ openai-agents==0.0.15
7
+ pdfplumber==0.11.4
8
+ python-multipart==0.0.12
9
+ httpx==0.27.2
routes/__init__.py ADDED
File without changes
routes/applications.py ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import logging
3
+ import asyncio
4
+ from datetime import datetime, timezone
5
+
6
+ from fastapi import APIRouter, HTTPException, UploadFile, File, Form
7
+
8
+ import sys
9
+ from pathlib import Path
10
+ sys.path.insert(0, str(Path(__file__).parent.parent))
11
+
12
+ from supabase_client import supabase
13
+ from email_service import (
14
+ send_selection_email,
15
+ send_rejection_email,
16
+ send_hr_notification_email,
17
+ )
18
+ from agent.resume_agent import analyze_resume
19
+
20
+ router = APIRouter()
21
+ logger = logging.getLogger(__name__)
22
+
23
+ RESUME_BUCKET = "resumes"
24
+ MAX_FILE_SIZE = 5 * 1024 * 1024
25
+ MIN_TEXT_LENGTH = 50
26
+ AI_TIMEOUT_SECONDS = 60
27
+
28
+
29
+ def _utcnow_str() -> str:
30
+ return datetime.now(timezone.utc).isoformat()
31
+
32
+
33
+ @router.post("/submit")
34
+ async def submit_application(
35
+ job_id: str = Form(...),
36
+ candidate_name: str = Form(...),
37
+ candidate_email: str = Form(...),
38
+ resume: UploadFile = File(...)
39
+ ):
40
+ candidate_name = candidate_name.strip()
41
+ candidate_email = candidate_email.strip().lower()
42
+
43
+ if not candidate_name:
44
+ raise HTTPException(status_code=400, detail="Candidate name is required.")
45
+ if not candidate_email or "@" not in candidate_email:
46
+ raise HTTPException(status_code=400, detail="Valid email address is required.")
47
+
48
+ content_type = resume.content_type or ""
49
+ filename = resume.filename or ""
50
+ if content_type not in ("application/pdf",) and not filename.lower().endswith(".pdf"):
51
+ raise HTTPException(status_code=400, detail="Only PDF files are accepted.")
52
+
53
+ try:
54
+ pdf_bytes = await resume.read()
55
+ except Exception as exc:
56
+ logger.error("Failed to read uploaded file: %s", exc)
57
+ raise HTTPException(status_code=400, detail="Could not read the uploaded file.")
58
+
59
+ if not pdf_bytes:
60
+ raise HTTPException(status_code=400, detail="Uploaded file is empty.")
61
+
62
+ if len(pdf_bytes) > MAX_FILE_SIZE:
63
+ raise HTTPException(status_code=400, detail="File size exceeds 5 MB limit.")
64
+
65
+ try:
66
+ job_response = supabase.table("jobs").select("*").eq("id", job_id).execute()
67
+ if not job_response.data:
68
+ raise HTTPException(status_code=404, detail="Job posting not found or link is invalid.")
69
+ job = job_response.data[0]
70
+ except HTTPException:
71
+ raise
72
+ except Exception as exc:
73
+ logger.error("Failed to fetch job %s: %s", job_id, exc)
74
+ raise HTTPException(status_code=500, detail="Could not verify job posting.")
75
+
76
+ if not job.get("is_active", True):
77
+ raise HTTPException(status_code=410, detail="This job posting is no longer accepting applications.")
78
+
79
+ try:
80
+ dup = (
81
+ supabase.table("applications")
82
+ .select("id")
83
+ .eq("job_id", job_id)
84
+ .eq("candidate_email", candidate_email)
85
+ .execute()
86
+ )
87
+ if dup.data:
88
+ raise HTTPException(
89
+ status_code=409,
90
+ detail="You have already applied for this position. Check your email for the result."
91
+ )
92
+ except HTTPException:
93
+ raise
94
+ except Exception as exc:
95
+ logger.warning("Duplicate check failed (continuing): %s", exc)
96
+
97
+ job_title = job.get("title", "Unknown Position")
98
+ company_name = job.get("company_name", "Our Company")
99
+ required_skills = job.get("required_skills", [])
100
+ experience_years = job.get("experience_years", 0)
101
+ job_description = job.get("description", "")
102
+ hr_email = job.get("hr_email")
103
+
104
+ application_id = str(uuid.uuid4())
105
+ applied_at = _utcnow_str()
106
+ storage_path = f"{job_id}/{application_id}.pdf"
107
+ resume_url = None
108
+
109
+ try:
110
+ supabase.storage.from_(RESUME_BUCKET).upload(
111
+ path=storage_path,
112
+ file=pdf_bytes,
113
+ file_options={"content-type": "application/pdf"}
114
+ )
115
+ resume_url = supabase.storage.from_(RESUME_BUCKET).get_public_url(storage_path)
116
+ except Exception as exc:
117
+ logger.warning("Resume storage upload failed (continuing): %s", exc)
118
+
119
+ try:
120
+ insert_resp = supabase.table("applications").insert({
121
+ "id": application_id,
122
+ "job_id": job_id,
123
+ "candidate_name": candidate_name,
124
+ "candidate_email": candidate_email,
125
+ "resume_url": resume_url,
126
+ "status": "pending",
127
+ "ats_score": None,
128
+ "missing_skills": [],
129
+ "ai_feedback": None,
130
+ "applied_at": applied_at,
131
+ }).execute()
132
+ if not insert_resp.data:
133
+ raise RuntimeError("Insert returned no data")
134
+ except Exception as exc:
135
+ logger.error("Failed to insert application: %s", exc)
136
+ raise HTTPException(status_code=500, detail="Failed to save application. Please try again.")
137
+
138
+ final_status = "pending"
139
+ ats_score = 0
140
+ missing_skills = []
141
+ ai_feedback = None
142
+ skill_recs = []
143
+ ai_result = None
144
+
145
+ try:
146
+ ai_result = await asyncio.wait_for(
147
+ analyze_resume(
148
+ pdf_bytes=pdf_bytes,
149
+ job_title=job_title,
150
+ job_description=job_description,
151
+ required_skills=required_skills,
152
+ experience_years=experience_years,
153
+ company_name=company_name,
154
+ ),
155
+ timeout=AI_TIMEOUT_SECONDS,
156
+ )
157
+ final_status = ai_result.get("status", "pending")
158
+ ats_score = int(ai_result.get("ats_score", 0))
159
+ missing_skills = ai_result.get("missing_skills", [])
160
+ ai_feedback = ai_result.get("ai_feedback", "")
161
+ skill_recs = ai_result.get("skill_recommendations", [])
162
+
163
+ except asyncio.TimeoutError:
164
+ logger.error("AI analysis timed out for application %s", application_id)
165
+ ai_feedback = "Analysis timed out. HR will review manually."
166
+ except ValueError as exc:
167
+ logger.error("PDF/AI error for application %s: %s", application_id, exc)
168
+ ai_feedback = str(exc)
169
+ except Exception as exc:
170
+ logger.error("AI analysis failed for application %s: %s", application_id, exc)
171
+
172
+ try:
173
+ supabase.table("applications").update({
174
+ "status": final_status,
175
+ "ats_score": ats_score,
176
+ "missing_skills": missing_skills,
177
+ "ai_feedback": ai_feedback,
178
+ "skill_recommendations": skill_recs,
179
+ "updated_at": _utcnow_str(),
180
+ }).eq("id", application_id).execute()
181
+ except Exception as exc:
182
+ logger.error("Failed to update application %s: %s", application_id, exc)
183
+
184
+ if ai_result is not None and final_status in ("selected", "rejected"):
185
+ try:
186
+ if final_status == "selected":
187
+ send_selection_email(
188
+ candidate_name=candidate_name,
189
+ candidate_email=candidate_email,
190
+ job_title=job_title,
191
+ company_name=company_name,
192
+ ats_score=ats_score,
193
+ )
194
+ if hr_email:
195
+ send_hr_notification_email(
196
+ hr_email=hr_email,
197
+ candidate_name=candidate_name,
198
+ candidate_email=candidate_email,
199
+ job_title=job_title,
200
+ company_name=company_name,
201
+ ats_score=ats_score,
202
+ applied_at=applied_at,
203
+ )
204
+ else:
205
+ send_rejection_email(
206
+ candidate_name=candidate_name,
207
+ candidate_email=candidate_email,
208
+ job_title=job_title,
209
+ company_name=company_name,
210
+ ats_score=ats_score,
211
+ missing_skills_with_recommendations=skill_recs,
212
+ )
213
+ except Exception as exc:
214
+ logger.error("Email failed for application %s: %s", application_id, exc)
215
+
216
+ logger.info("Application %s processed: status=%s ats=%s", application_id, final_status, ats_score)
217
+
218
+ return {
219
+ "success": True,
220
+ "application_id": application_id,
221
+ "status": final_status,
222
+ "ats_score": ats_score,
223
+ "candidate_name": candidate_name,
224
+ "missing_skills": missing_skills,
225
+ "ai_feedback": ai_feedback,
226
+ "skill_recommendations": skill_recs,
227
+ }
228
+
229
+
230
+ @router.get("/{job_id}")
231
+ async def get_applications(job_id: str):
232
+ try:
233
+ response = (
234
+ supabase.table("applications")
235
+ .select("*")
236
+ .eq("job_id", job_id)
237
+ .order("applied_at", desc=True)
238
+ .execute()
239
+ )
240
+ except Exception as exc:
241
+ logger.error("Failed to fetch applications for job %s: %s", job_id, exc)
242
+ raise HTTPException(status_code=500, detail="Failed to retrieve applications.")
243
+
244
+ return {
245
+ "success": True,
246
+ "job_id": job_id,
247
+ "applications": response.data or [],
248
+ "total": len(response.data or []),
249
+ }
routes/hr.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from fastapi import APIRouter, HTTPException
3
+ from pydantic import BaseModel, EmailStr
4
+
5
+ import sys
6
+ from pathlib import Path
7
+ sys.path.insert(0, str(Path(__file__).parent.parent))
8
+
9
+ from supabase_client import supabase
10
+
11
+ router = APIRouter()
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class HRRegisterRequest(BaseModel):
16
+ id: str
17
+ name: str
18
+ company_name: str
19
+ email: EmailStr
20
+
21
+
22
+ @router.post("/register")
23
+ async def register_hr(payload: HRRegisterRequest):
24
+ try:
25
+ response = supabase.table("hr_users").upsert({
26
+ "id": payload.id,
27
+ "name": payload.name,
28
+ "company_name": payload.company_name,
29
+ "email": payload.email,
30
+ }).execute()
31
+ except Exception as exc:
32
+ logger.error("Failed to insert HR user: %s", exc)
33
+ raise HTTPException(status_code=500, detail="Failed to save HR profile.")
34
+
35
+ logger.info("HR user registered: id=%s email=%s", payload.id, payload.email)
36
+ return {"success": True, "user_id": payload.id}
routes/jobs.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import uuid
2
+ import logging
3
+ from fastapi import APIRouter, HTTPException
4
+ from pydantic import BaseModel, EmailStr
5
+ from typing import List
6
+
7
+ import sys
8
+ from pathlib import Path
9
+ sys.path.insert(0, str(Path(__file__).parent.parent))
10
+
11
+ from supabase_client import supabase
12
+
13
+ router = APIRouter()
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class CreateJobRequest(BaseModel):
18
+ title: str
19
+ company_name: str
20
+ description: str
21
+ required_skills: List[str]
22
+ experience_years: int
23
+ hr_id: str
24
+ hr_email: str
25
+
26
+
27
+ @router.post("/create")
28
+ async def create_job(payload: CreateJobRequest):
29
+ unique_link = str(uuid.uuid4())
30
+
31
+ job_data = {
32
+ "title": payload.title,
33
+ "company_name": payload.company_name,
34
+ "description": payload.description,
35
+ "required_skills": payload.required_skills,
36
+ "experience_years": payload.experience_years,
37
+ "hr_id": payload.hr_id,
38
+ "hr_email": payload.hr_email,
39
+ "unique_link": unique_link,
40
+ "is_active": True,
41
+ }
42
+
43
+ try:
44
+ response = supabase.table("jobs").insert(job_data).execute()
45
+ except Exception as exc:
46
+ logger.error("Failed to create job in Supabase: %s", exc)
47
+ raise HTTPException(status_code=500, detail="Failed to create job posting.")
48
+
49
+ if not response.data:
50
+ raise HTTPException(status_code=500, detail="Job creation returned no data.")
51
+
52
+ created_job = response.data[0]
53
+ logger.info("Job created: id=%s title=%s", created_job.get("id"), payload.title)
54
+ return {
55
+ "success": True,
56
+ "job": created_job,
57
+ "unique_link": unique_link,
58
+ "application_url": f"/apply/{unique_link}"
59
+ }
60
+
61
+
62
+ @router.get("/{job_id}")
63
+ async def get_job(job_id: str):
64
+ try:
65
+ response = supabase.table("jobs").select("*").eq("unique_link", job_id).execute()
66
+ if not response.data:
67
+ response = supabase.table("jobs").select("*").eq("id", job_id).execute()
68
+ except Exception as exc:
69
+ logger.error("Failed to fetch job %s: %s", job_id, exc)
70
+ raise HTTPException(status_code=500, detail="Failed to retrieve job details.")
71
+
72
+ if not response.data:
73
+ raise HTTPException(status_code=404, detail="Job not found.")
74
+
75
+ return {"success": True, "job": response.data[0]}
76
+
77
+
78
+ @router.get("/hr/{hr_id}")
79
+ async def get_hr_jobs(hr_id: str):
80
+ try:
81
+ jobs_response = (
82
+ supabase.table("jobs")
83
+ .select("*")
84
+ .eq("hr_id", hr_id)
85
+ .order("created_at", desc=True)
86
+ .execute()
87
+ )
88
+ except Exception as exc:
89
+ logger.error("Failed to fetch jobs for hr_id=%s: %s", hr_id, exc)
90
+ raise HTTPException(status_code=500, detail="Failed to retrieve jobs.")
91
+
92
+ jobs = jobs_response.data or []
93
+
94
+ enriched_jobs = []
95
+ for job in jobs:
96
+ job_id = job.get("id")
97
+ try:
98
+ count_response = (
99
+ supabase.table("applications")
100
+ .select("id", count="exact")
101
+ .eq("job_id", job_id)
102
+ .execute()
103
+ )
104
+ applicant_count = count_response.count if count_response.count is not None else 0
105
+ except Exception as exc:
106
+ logger.warning("Could not fetch applicant count for job %s: %s", job_id, exc)
107
+ applicant_count = 0
108
+
109
+ enriched_jobs.append({**job, "applicant_count": applicant_count})
110
+
111
+ return {"success": True, "jobs": enriched_jobs, "total": len(enriched_jobs)}
supabase_client.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from supabase import create_client, Client
2
+ from dotenv import load_dotenv
3
+ import os
4
+
5
+ load_dotenv()
6
+
7
+ SUPABASE_URL = os.getenv("SUPABASE_URL")
8
+ SUPABASE_SERVICE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY")
9
+
10
+ if not SUPABASE_URL or not SUPABASE_SERVICE_KEY:
11
+ raise ValueError("SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY must be set in environment variables")
12
+
13
+ supabase: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)