diff --git a/backend/job/app.py b/backend/job/app.py index e860f82..b708d6f 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -135,6 +135,7 @@ async def create_manual_candidate( candidate_phone: str | None = Form(None), job_post_id: str | None = Form(None), current_company: str | None = Form(None), + current_position: str | None = Form(None), platform: str | None = Form(None), experience: str | None = Form(None), status: str | None = Form(None), @@ -159,6 +160,7 @@ async def create_manual_candidate( candidate_phone=candidate_phone, job_post_id=job_post_id, current_company=current_company, + current_position=current_position, platform=platform, experience=experience, status=status, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 2fffa91..72b84bc 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -31,6 +31,10 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id") full_text: str = Field(default="") current_company: str = Field(default="") + # Candidate's role at that company (e.g. "Senior Merchandiser"). Distinct + # from job_posts.title — that is the role they applied to, not their own. + # Same ALTER-on-a-populated-table reasoning as referral_by below. + current_position: str = Field(default="", sa_column_kwargs={"server_default": ""}) user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") platform: str = Field(default="") created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") @@ -98,6 +102,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): job_post_id=cls._as_uuid(fields.get("job_post_id")), full_text=fields.get("full_text") or "", current_company=(fields.get("current_company") or "").strip(), + current_position=(fields.get("current_position") or "").strip(), user_id=user.id, platform=(fields.get("platform") or "").strip(), created_by=cls._as_uuid(fields.get("created_by")), @@ -112,6 +117,16 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): await session.refresh(row) return row + @classmethod + async def get_by_user_id(cls, session: AsyncSession, user_id): + uid = cls._as_uuid(user_id) + if uid is None: + return None + result = await session.execute( + select(cls).where(cls.user_id == uid).order_by(cls.created_at.desc()) + ) + return result.scalars().first() + class Candidates(SQLModel, table=True): """One scored (or failed-to-score) CV against one job post. diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index c1e15fa..f5b2e07 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -5,6 +5,7 @@ from job.candidate.plugins import documents_from_message, source_from_message_to from job.interviews.serializers import serialize_interview from job.activity.serializers import serialize_activity from job.feedback.serializers import serialize_feedback +from job.job_post.serializers import serialize_job_post def serialize_candidate(row) -> dict: return { @@ -42,6 +43,7 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]: "job_post_id":str(row.job_post_id) if row.job_post_id else None, "full_text":row.full_text, "current_company":row.current_company, + "current_position":row.current_position, "user_id":str(row.user_id) if row.user_id else None, "platform":row.platform, "created_by":str(row.created_by) if row.created_by else None, @@ -118,3 +120,64 @@ def serialize_candidate_profile( "notes": [], }) return payload + + +def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]: + """Same key vocabulary as serialize_candidate_profile(detail=True). + + Manual uploads never go through inbox, so current_position maps onto + current_title here and the agent/ATS fields stay empty. + """ + job_payload = serialize_job_post(job_post) if job_post else None + created = row.created_at.isoformat() if row.created_at else None + file_name = (row.file_name or "").strip() or None + file_path = (row.file_path or "").strip() or None + documents = [{"name": file_name or "", "path": file_path or ""}] if (file_name or file_path) else [] + company = (row.current_company or "").strip() or None + position = (row.current_position or "").strip() or None + return { + "inbox_id": None, + "user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None), + "name": (user.name if user else None) or row.candidate_name or None, + "email": (user.email if user else None) or row.candidate_email or None, + "is_active": user.is_active if user else None, + "message_id": None, + "created_at": created, + "application_status": row.status or None, + "experience": (row.experience or "").strip() or None, + "current_employment": company, + "current_title": position, + "resume_text": row.full_text or None, + "suggested_job_post_ids": [], + "assigned_job_post_id": str(row.job_post_id) if row.job_post_id else None, + "match_summary": None, + "match_reasoning": None, + "match_status": None, + "match_error": None, + "matched_at": None, + "job_posts": [job_payload] if job_payload else [], + "favorite": None, + "rating": None, + "phone": (row.candidate_phone or "").strip() or None, + "education": None, + "currentCompany": company, + "stage": row.status or None, + "source": (row.platform or "").strip() or None, + "applied": created, + "documents": documents, + "recruiter": job_payload.get("created_by_name") if job_payload else None, + "recruiter_id": job_payload.get("created_by") if job_payload else None, + "job_title": job_payload.get("title") if job_payload else None, + "ai_score": None, + "recommendation": None, + "sub_scores": None, + "interviews": [], + "activity": [], + "feedback": [], + "notes": [], + "assigned_job_post": job_payload, + "matched_keywords": [], + "missing_keywords": [], + "summary_critique": None, + "scored_at": None, + } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 7d36fb5..62d3167 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -21,13 +21,13 @@ from job.candidate.plugins import ( get_scoring_settings, normalize_spaced_text, ) -from job.candidate.serializers import serialize_candidate,serialize_candidate_profile +from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post -from job.candidate.serializers import serialize_manual_upload_candidate from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE from job.notes.serializers import serialize_note from job.candidate.plugins import extract_candidate_email +from users.models import Users load_dotenv() logger=logging.getLogger("job.candidate.views") @@ -467,7 +467,7 @@ class CandidateView: scores.setdefault(row.inbox_message_id,[]).append(row) return scores - async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None): + async def create_candidate(self,candidate_email=None,candidate_name=None,candidate_phone=None,job_post_id=None,current_company=None,current_position=None,platform=None,experience=None,status=None,referral_by=None,file_name=None,file_path=None,full_text=None,current_user=None): try: email=(candidate_email or "").strip().lower() if not email: @@ -480,6 +480,7 @@ class CandidateView: "candidate_phone":(candidate_phone or "").strip(), "job_post_id":job_post_id, "current_company":(current_company or "").strip(), + "current_position":(current_position or "").strip(), "platform":(platform or "").strip(), "experience":(experience or "").strip(), "status":(status or "").strip(), @@ -504,7 +505,19 @@ class CandidateView: fetch_limit=1000 if detail else limit rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search) if detail: - return await self.attach_profile_detail(rows) + records=rows if isinstance(rows,list) else ([rows] if rows else []) + if records: + return await self.attach_profile_detail(rows) + # Manual uploads create users + manual_upload_candidate but no inbox + # row — resolve the profile from that table instead of returning []. + manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id) + if not manual: + return [] + user=await Users.get_user_by_id(self.session,user_id) + job_post=None + if manual.job_post_id: + job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id)) + return serialize_manual_candidate_profile(manual,user,job_post) return await self.attach_job_posts(rows) except HTTPException: raise diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index fe4944e..8741241 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -219,7 +219,7 @@ export function update(userId, payload) { * the recruiter typed — often someone with no account here. */ export function createManual({ - file, name, email, phone, jobPostId, company, source, experience, stage, referralBy, + file, name, email, phone, jobPostId, company, currentPosition, source, experience, stage, referralBy, }) { const form = new FormData() form.append('file', file) @@ -234,6 +234,7 @@ export function createManual({ put('candidate_phone', phone) put('job_post_id', jobPostId) put('current_company', company) + put('current_position', currentPosition) put('platform', source) put('experience', experience) put('status', stage) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 35a335e..6144436 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -511,7 +511,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) { const form = useFormState({ name: '', email: '', phone: '', job: '', - experience: '3', company: '', source: sources[0], stage: stages[0], + experience: '3', company: '', position: '', source: sources[0], stage: stages[0], referral: '', }) @@ -573,6 +573,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) { phone: v.phone, jobPostId, company: v.company, + currentPosition: v.position, source: v.source, experience: v.experience, stage: v.stage, @@ -629,6 +630,7 @@ function AddCandidate({ onClose, onSave, onInvalid }) {
+{view.critique ?? '—'}
+{c.critique ?? '—'}
-