From 6b4ad6b0ddc243f293d35cf5202a0af5aaa8db91 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 4 Sep 2026 19:06:22 +0500 Subject: [PATCH] Implement application history retrieval for candidates and enhance reapplication tracking - Added methods to retrieve application history by email across various sources (inbox, manual uploads, form data). - Introduced new serializers for application history items and overall history. - Updated candidate and inbox views to include application history in responses. - Enhanced frontend components to display reapplication badges and previous application details. - Adjusted API endpoints to support fetching application history based on email input. --- backend/README.md | 2 +- backend/g_sheet/models.py | 38 ++++ backend/g_sheet/views.py | 5 +- backend/inbox/models.py | 50 +++++ backend/inbox/views.py | 10 +- backend/job/app.py | 16 ++ backend/job/candidate/models.py | 64 ++++++ backend/job/candidate/serializers.py | 32 +++ backend/job/candidate/views.py | 197 +++++++++++++++++- backend/job/pipeline/views.py | 4 + backend/org_settings/models.py | 4 +- backend/talent/plugins.py | 2 +- backend/users/models.py | 13 ++ frontend/src/api/candidates.js | 12 ++ frontend/src/api/pipeline.js | 2 + .../src/components/ReapplicantHistory.jsx | 138 ++++++++++++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/CandidateProfile.jsx | 7 +- frontend/src/screens/Candidates.jsx | 50 ++++- frontend/src/screens/Inbox.jsx | 22 +- frontend/src/screens/JobCandidates.jsx | 12 +- frontend/src/screens/Pipeline.jsx | 2 + frontend/src/screens/Settings.jsx | 38 ++-- frontend/src/screens/TalentPool.jsx | 8 +- frontend/src/styles/styles.css | 17 ++ 25 files changed, 707 insertions(+), 39 deletions(-) create mode 100644 frontend/src/components/ReapplicantHistory.jsx diff --git a/backend/README.md b/backend/README.md index 8edc020..34f5f14 100644 --- a/backend/README.md +++ b/backend/README.md @@ -928,7 +928,7 @@ own keys with `os.getenv` from the same file. | `APIFY_MAX_COST_USD` | `1.0` | Sent as `maxTotalChargeUsd`; Apify's minimum is $0.10 | | `APIFY_TIMEOUT` | `30` | Per-request httpx timeout, seconds | | `APIFY_EXCLUDE_COMPANIES` | `Utopia Brands,Utopia Deals` | Own companies: current employees are filtered out server-side before profiles are stored (case-insensitive substring on current company, headline fallback) | -| `APIFY_EXCLUDE_COMPANY_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all | +| `APIFY_exclude_company_URLS` | the Utopia Deals / Utopia Brands USA / Utopia Brands Pakistan pages | Full LinkedIn company URLs for the actor's `excludeCurrentCompanies` filter — stops those profiles being scraped (and billed) at all | ### OpenAI diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 9f19807..c40ff7e 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -295,6 +295,44 @@ class FormData(SQLModel, table=True): result = await session.execute(statement) return result.scalars().all() + @classmethod + async def list_by_emails(cls, session: AsyncSession, emails): + """Sheet applicants for these addresses. Promoted rows are omitted — + those already live on manual_upload_candidate.""" + from job.job_post.models import JobPosts + + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + assigned = func.coalesce(cls.assigned_job_post_id, cls.job_post_id) + result = await session.execute( + select(cls, JobPosts.title) + .outerjoin(JobPosts, assigned == JobPosts.id) + .where(func.lower(cls.candidate_email).in_(lowers)) + .where(cls.manual_upload_candidate_id.is_(None)) + .order_by(cls.created_at.desc()) + ) + rows = [] + for rec, title in result.all(): + job_id = rec.assigned_job_post_id or rec.job_post_id + rows.append({ + "source": "form", + "email": (rec.candidate_email or "").strip().lower() or None, + "inbox_id": None, + "message_id": None, + "manual_upload_candidate_id": None, + "form_data_id": str(rec.id), + "candidate_id": None, + "job_post_id": str(job_id) if job_id else None, + "job_title": title or rec.position_applied_for or None, + "status": rec.processing_state or None, + "applied_at": ( + rec.entry_date.isoformat() if rec.entry_date + else (rec.created_at.isoformat() if rec.created_at else None) + ), + }) + return rows + @classmethod async def count_form_data( cls, session: AsyncSession, *, sheet=None, search=None, diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 7080edd..ec32b82 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -443,6 +443,8 @@ class SheetFormData(Sheet): processing_state=processing_state,is_duplicate=is_duplicate, ) items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows]) + from job.candidate.views import CandidateView + items=await CandidateView(session=session).attach_application_history(items) return items,total async def get_form_data_by_id(self,record_id): @@ -451,7 +453,8 @@ class SheetFormData(Sheet): if not row: raise HTTPException(status_code=404,detail="Form data not found") items=await self._hydrate_job_posts([serialize_form_data(row)]) - return items[0] + from job.candidate.views import CandidateView + return await CandidateView(session=session).attach_application_history(items[0]) async def assign_job_post(self,record_id,job_post_id): """Set or clear form_data.assigned_job_post_id (same contract as inbox assign). diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 25af7c2..a39c3a7 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -160,6 +160,56 @@ class Inbox(SQLModel, table=True): except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + @classmethod + async def list_applications_by_emails(cls, session: AsyncSession, emails): + """Inbox applications whose user or sender address is in `emails`.""" + from job.job_post.models import JobPosts + + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result = await session.execute( + select( + cls.id.label("inbox_id"), + cls.user_id, + cls.message_id, + Users.email, + Inbox_Messages.message_from, + Inbox_Messages.assigned_job_post_id, + Inbox_Messages.application_status, + Inbox_Messages.message_received_time, + cls.created_at, + JobPosts.title, + ) + .join(Users, cls.user_id == Users.id) + .join(Inbox_Messages, cls.message_id == Inbox_Messages.id) + .outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id) + .where(or_( + func.lower(Users.email).in_(lowers), + func.lower(Inbox_Messages.message_from).in_(lowers), + )) + .order_by(cls.created_at.desc()) + ) + rows = [] + for row in result.mappings().all(): + email = (row["email"] or row["message_from"] or "").strip().lower() or None + status = row["application_status"] + applied = row["message_received_time"] or row["created_at"] + rows.append({ + "source": "inbox", + "email": email, + "inbox_id": row["inbox_id"], + "message_id": str(row["message_id"]) if row["message_id"] else None, + "manual_upload_candidate_id": None, + "form_data_id": None, + "candidate_id": None, + "job_post_id": str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None, + "job_title": row["title"] or None, + "status": status.value if status else None, + "applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None), + }) + return rows + @classmethod async def linkedin_urls_by_message_ids(cls, session: AsyncSession, message_ids) -> dict: """users.linkedin_url keyed by inbox_messages.id for one list page.""" diff --git a/backend/inbox/views.py b/backend/inbox/views.py index c9122d2..2e58b43 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -271,7 +271,7 @@ class Email: item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id) else: item["assigned_job_post"]=None - return item + return await cv.attach_application_history(item) async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None): if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state: @@ -281,14 +281,18 @@ class Email: else: messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,light=True) urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) - return [serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages] + items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages] + from job.candidate.views import CandidateView + return await CandidateView(session=self.session).attach_application_history(items) async def get_application_by_id(self,record_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) if not message: raise HTTPException(status_code=404,detail="Application not found") urls=await Inbox.linkedin_urls_by_message_ids(self.session,[message.id]) - return serialize_application(message,linkedin_url=urls.get(message.id)) + item=serialize_application(message,linkedin_url=urls.get(message.id)) + from job.candidate.views import CandidateView + return await CandidateView(session=self.session).attach_application_history(item) async def queue_rematch(self,record_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) diff --git a/backend/job/app.py b/backend/job/app.py index 6bfc255..70fee20 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -969,6 +969,22 @@ async def fetch_candidate( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/candidate/applications/fetch") +async def fetch_candidate_applications( + email:str=Query(...), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + try: + service=CandidateView(session=session) + data=await service.get_application_history(email) + return JSONResponse(content={"data":data,"total":len(data.get("applications") or []),"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.patch("/candidate/update") async def update_candidate( user_id:str=Query(...), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 262dd25..0ac06bf 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -389,6 +389,38 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def list_by_emails(cls, session: AsyncSession, emails): + """Every manual/form/cv-bank row for these addresses, newest first.""" + from job.job_post.models import JobPosts + + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result = await session.execute( + select(cls, JobPosts.title) + .outerjoin(JobPosts, cls.job_post_id == JobPosts.id) + .where(func.lower(cls.candidate_email).in_(lowers)) + .order_by(cls.created_at.desc()) + ) + rows = [] + for rec, title in result.all(): + status = (rec.status or "").strip() or None + rows.append({ + "source": "manual", + "email": (rec.candidate_email or "").strip().lower() or None, + "inbox_id": None, + "message_id": None, + "manual_upload_candidate_id": str(rec.id), + "form_data_id": None, + "candidate_id": None, + "job_post_id": str(rec.job_post_id) if rec.job_post_id else None, + "job_title": title or None, + "status": status or "PENDING", + "applied_at": rec.created_at.isoformat() if rec.created_at else None, + }) + return rows + @classmethod async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None): """Newest applications with a user + job for Talent Pool (manual / form).""" @@ -807,6 +839,38 @@ class Candidates(SQLModel, table=True): ) return result.scalars().first() + @classmethod + async def list_by_emails(cls, session: AsyncSession, emails): + """Scored `candidates` rows for these addresses (ATS, not pipeline stage).""" + from job.job_post.models import JobPosts + + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + result = await session.execute( + select(cls, JobPosts.title) + .outerjoin(JobPosts, cls.job_id == JobPosts.id) + .where(func.lower(cls.candidate_email).in_(lowers)) + .order_by(cls.created_at.desc()) + ) + rows = [] + for rec, title in result.all(): + email = (rec.candidate_email or "").strip().lower() or None + rows.append({ + "source": "ats", + "email": email, + "inbox_id": None, + "message_id": None, + "manual_upload_candidate_id": None, + "form_data_id": None, + "candidate_id": str(rec.id), + "job_post_id": str(rec.job_id) if rec.job_id else None, + "job_title": title or rec.job_title or None, + "status": rec.status or None, + "applied_at": rec.created_at.isoformat() if rec.created_at else None, + }) + return rows + @classmethod async def upsert_candidate(cls, session: AsyncSession, fields: dict): existing = None diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 8900e6e..e79371d 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -248,3 +248,35 @@ def serialize_manager_candidate(row, *, source) -> dict: "ai_score": score, "recommendation": band, } + + +def serialize_application_history_item(row) -> dict: + """One prior application / score / sheet row for a reapplicant lookup.""" + return { + "source": row.get("source"), + "inbox_id": row.get("inbox_id"), + "message_id": row.get("message_id"), + "manual_upload_candidate_id": row.get("manual_upload_candidate_id"), + "form_data_id": row.get("form_data_id"), + "candidate_id": row.get("candidate_id"), + "job_post_id": row.get("job_post_id"), + "job_title": row.get("job_title"), + "status": row.get("status"), + "applied_at": row.get("applied_at"), + } + + +def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict: + items = [serialize_application_history_item(row) for row in (applications or [])] + found = bool(user or present_in or items) + return { + "email": email, + "found": found, + "present_in": list(present_in or []), + "user": ( + {"id": str(user.id), "name": user.name, "email": user.email} + if user is not None else None + ), + "is_reapplicant": len(items) > 0, + "applications": items, + } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 8e08abf..a4b9a96 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -23,7 +23,8 @@ from job.candidate.plugins import ( get_scoring_settings, normalize_spaced_text, ) -from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate +from g_sheet.models import FormData +from job.candidate.serializers import serialize_application_history,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE @@ -43,6 +44,62 @@ MANUAL_UPLOAD_TO_ADDRESS=os.getenv( ) MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions" CREATOR_SCOPE_DETAIL="You can only access candidates allocated to jobs you created" +_HISTORY_TABLES=("users","candidates","manual_upload_candidate","form_data") + + +def _norm_email(value): + raw=(value or "").strip().lower() + if not raw: + return "" + if "<" in raw and ">" in raw: + inner=raw.rsplit("<",1)[-1] + raw=inner.split(">",1)[0].strip() + return raw + + +def _payload_email(payload): + if not isinstance(payload,dict): + return "" + return _norm_email( + payload.get("email") + or payload.get("candidate_email") + or payload.get("fromEmail") + ) + + +def _is_current_application(item,payload): + """True when `item` is the same row the list/detail payload is showing.""" + if not isinstance(item,dict) or not isinstance(payload,dict): + return False + source=item.get("source") + if source=="inbox": + if payload.get("inbox_id") is not None and item.get("inbox_id") is not None: + try: + if int(payload["inbox_id"])==int(item["inbox_id"]): + return True + except (TypeError,ValueError): + pass + pid=payload.get("message_id") + if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None: + pid=payload.get("id") + return bool(pid and item.get("message_id") and str(pid)==str(item["message_id"])) + if source=="manual": + pid=payload.get("manual_upload_candidate_id") + if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None: + pid=payload.get("id") + return bool(pid and item.get("manual_upload_candidate_id") and str(pid)==str(item["manual_upload_candidate_id"])) + if source=="form": + if payload.get("sheet") is None: + return False + pid=payload.get("id") + return bool(pid and item.get("form_data_id") and str(pid)==str(item["form_data_id"])) + if source=="ats": + pid=payload.get("candidate_id") or payload.get("id") + return bool( + pid and item.get("candidate_id") and str(pid)==str(item["candidate_id"]) + and (payload.get("match_score") is not None or payload.get("filename")) + ) + return False async def assigned_job_ids_for_user(session,user_id): @@ -473,13 +530,16 @@ class CandidateScoring: rows,total=await Candidates.get_candidates_by_job( self.session,job_id,limit=limit,offset=offset, ) - return [serialize_candidate(row) for row in rows],total + data=[serialize_candidate(row) for row in rows] + data=await CandidateView(session=self.session).attach_application_history(data) + return data,total async def fetch_candidate_by_id(self,candidate_id): row=await Candidates.get_candidate_by_id(self.session,candidate_id) if row is None: raise HTTPException(status_code=404,detail="Candidate not found") - return serialize_candidate(row) + data=serialize_candidate(row) + return await CandidateView(session=self.session).attach_application_history(data) async def _score_and_persist(self,job_id,sources,source_kind,current_user): job=await JobPosts.get_job_post_by_id(self.session,job_id) @@ -820,7 +880,8 @@ class CandidateView: total=len(merged) start=max(0,int(offset or 0)) cap=max(1,int(limit or 50)) - return merged[start:start+cap],total + page=merged[start:start+cap] + return await self.attach_application_history(page),total async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False): try: @@ -854,10 +915,10 @@ class CandidateView: if jid and jid in owned_set: kept.append(rec) if kept: - return await self.attach_profile_detail(kept) + return await self.attach_application_history(await self.attach_profile_detail(kept)) records=[] if records: - return await self.attach_profile_detail(rows) + return await self.attach_application_history(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) @@ -881,9 +942,9 @@ class CandidateView: payload["candidate_id"]=score.get("candidate_id") if score.get("user_id") and not payload.get("user_id"): payload["user_id"]=score["user_id"] - if score.get("job_post_id"): - payload["scored_job_post_id"]=score["job_post_id"] - return payload + if score.get("job_post_id"): + payload["scored_job_post_id"]=score["job_post_id"] + return await self.attach_application_history(payload) # List mode: inbox applications + manual/form applications (dedupe by user). inbox_payloads=await self.attach_job_posts(rows) if not isinstance(inbox_payloads,list): @@ -944,7 +1005,7 @@ class CandidateView: if assigned_job_post_id: job_id=str(assigned_job_post_id) data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id] - return data + return await self.attach_application_history(data) except HTTPException: raise except Exception as e: @@ -997,7 +1058,7 @@ class CandidateView: from_value=old_rating,to_value=fields["rating"],commit=True, ) refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0) - return await self.attach_profile_detail(refreshed) + return await self.attach_application_history(await self.attach_profile_detail(refreshed)) except HTTPException: raise except Exception as e: @@ -1303,3 +1364,117 @@ class CandidateView: description="cv_bank",commit=True, ) return serialize_matching_candidate(row,job_post) + + async def application_history_by_emails(self,emails): + """Prior applications keyed by lowercase email across users / inbox / + manual_upload_candidate / form_data / candidates.""" + lowers=[] + seen=set() + for raw in emails or []: + email=_norm_email(raw) + if email and email not in seen: + seen.add(email) + lowers.append(email) + if not lowers: + return {} + users=await Users.get_users_by_emails(self.session,lowers) + user_by_email={_norm_email(u.email):u for u in users} + inbox_rows=await Inbox.list_applications_by_emails(self.session,lowers) + manual_rows=await Manual_UPLOAD_CANDIDATE.list_by_emails(self.session,lowers) + form_rows=await FormData.list_by_emails(self.session,lowers) + ats_rows=await Candidates.list_by_emails(self.session,lowers) + packed={email:{"present_in":[],"user":user_by_email.get(email),"applications":[]} for email in lowers} + for email,user in user_by_email.items(): + if email in packed: + packed[email]["present_in"].append("users") + packed[email]["user"]=user + for row in inbox_rows: + email=_norm_email(row.get("email")) + if email in packed: + packed[email]["applications"].append(row) + for row in manual_rows: + email=_norm_email(row.get("email")) + if email not in packed: + continue + packed[email]["applications"].append(row) + if "manual_upload_candidate" not in packed[email]["present_in"]: + packed[email]["present_in"].append("manual_upload_candidate") + for row in form_rows: + email=_norm_email(row.get("email")) + if email not in packed: + continue + packed[email]["applications"].append(row) + if "form_data" not in packed[email]["present_in"]: + packed[email]["present_in"].append("form_data") + for row in ats_rows: + email=_norm_email(row.get("email")) + if email not in packed: + continue + packed[email]["applications"].append(row) + if "candidates" not in packed[email]["present_in"]: + packed[email]["present_in"].append("candidates") + pipeline_jobs={} + for email,pack in packed.items(): + jobs=set() + for row in pack["applications"]: + if row.get("source") in ("inbox","manual") and row.get("job_post_id"): + jobs.add(row["job_post_id"]) + pipeline_jobs[email]=jobs + for email,pack in packed.items(): + jobs=pipeline_jobs.get(email) or set() + if not jobs: + continue + # ATS scores for a job the person already applied to are not a + # separate application — they duplicate the pipeline row. + pack["applications"]=[ + row for row in pack["applications"] + if not (row.get("source")=="ats" and row.get("job_post_id") in jobs) + ] + for pack in packed.values(): + pack["applications"].sort(key=lambda r: r.get("applied_at") or "",reverse=True) + present=pack["present_in"] + pack["present_in"]=[name for name in _HISTORY_TABLES if name in present] + return packed + + async def get_application_history(self,email): + cleaned=_norm_email(email) + if not cleaned: + raise HTTPException(status_code=422,detail="email is required") + packed=await self.application_history_by_emails([cleaned]) + pack=packed.get(cleaned) or {"present_in":[],"user":None,"applications":[]} + return serialize_application_history( + cleaned,user=pack.get("user"),present_in=pack.get("present_in"), + applications=pack.get("applications"), + ) + + async def attach_application_history(self,payloads): + """Stamp is_reapplicant + previous_applications onto list/detail dicts.""" + single=not isinstance(payloads,list) + records=[payloads] if single else list(payloads or []) + emails=[_payload_email(p) for p in records] + history=await self.application_history_by_emails(emails) + for payload in records: + if not isinstance(payload,dict): + continue + email=_payload_email(payload) + pack=history.get(email) or {"present_in":[],"user":None,"applications":[]} + previous=[] + for row in pack.get("applications") or []: + if _is_current_application(row,payload): + continue + previous.append({ + "source":row.get("source"), + "inbox_id":row.get("inbox_id"), + "message_id":row.get("message_id"), + "manual_upload_candidate_id":row.get("manual_upload_candidate_id"), + "form_data_id":row.get("form_data_id"), + "candidate_id":row.get("candidate_id"), + "job_post_id":row.get("job_post_id"), + "job_title":row.get("job_title"), + "status":row.get("status"), + "applied_at":row.get("applied_at"), + }) + payload["present_in"]=list(pack.get("present_in") or []) + payload["is_reapplicant"]=bool(previous) + payload["previous_applications"]=previous + return records[0] if single else records diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py index 1db7ed4..f3128d5 100644 --- a/backend/job/pipeline/views.py +++ b/backend/job/pipeline/views.py @@ -21,6 +21,10 @@ class Pipeline: try: inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) + from job.candidate.views import CandidateView + history=CandidateView(session=self.session) + inbox_data=await history.attach_application_history(inbox_data) + manual_upload_data=await history.attach_application_history(manual_upload_data) counts=serialize_pipeline_counts( await Inbox.count_by_status(self.session,job_post_id=job_post_id), await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id), diff --git a/backend/org_settings/models.py b/backend/org_settings/models.py index 37d4051..0ad51c1 100644 --- a/backend/org_settings/models.py +++ b/backend/org_settings/models.py @@ -77,7 +77,7 @@ class OrgSettings(SQLModel, table=True): class ExcludeUniversity(SQLModel, table=True): - __tablename__ = "Exclude_University" + __tablename__ = "exclude_university" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) name: str = Field(index=True) @@ -166,7 +166,7 @@ class ExcludeUniversity(SQLModel, table=True): class ExcludeCompany(SQLModel, table=True): - __tablename__ = "Exclude_Company" + __tablename__ = "exclude_company" id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) name: str = Field(index=True) diff --git a/backend/talent/plugins.py b/backend/talent/plugins.py index e71ee70..89c6229 100644 --- a/backend/talent/plugins.py +++ b/backend/talent/plugins.py @@ -48,7 +48,7 @@ def is_excluded_profile(profile: dict, *, companies=None, universities=None) -> Company: current employer, or headline only when no company was extracted so an "ex-…" headline on someone now elsewhere does not exclude them. University: any education school name on the sourced profile. - Lists come from Exclude_Company / Exclude_University — never hardcoded. + Lists come from exclude_company / exclude_university — never hardcoded. """ company = (profile or {}).get("current_company") if _matches_excluded(company, companies): diff --git a/backend/users/models.py b/backend/users/models.py index 5cdfdd5..34d9082 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -181,6 +181,19 @@ class Users(SQLModel, table=True): result = await session.execute(statement) return result.scalars().first() + @classmethod + async def get_users_by_emails(cls, session: AsyncSession, emails): + """Any account matching these addresses, including candidate role_id=8.""" + lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()}) + if not lowers: + return [] + statement = ( + select(cls) + .where(func.lower(cls.email).in_(lowers), cls.is_deleted == False) # noqa: E712 + ) + result = await session.execute(statement) + return list(result.scalars().all()) + @classmethod async def count_users(cls, session: AsyncSession, search: str | None = None, role_id: Optional[int] = None): statement = ( diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 4544e42..4b9acec 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -148,6 +148,8 @@ export function toCandidateView(row) { errorCode: row.error_code ?? null, errorMessage: row.error_message ?? null, applied: toDate(row.created_at), + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } } @@ -239,6 +241,8 @@ export function toApplicationListView(row) { aiScore: score, recommendation: bandOf(score, row.recommendation || null), applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null), + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } } @@ -425,6 +429,14 @@ export function listHistory(userId, { limit = 10, offset = 0 } = {}) { return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } }) } +/** + * Prior applications for one email across users, candidates, manual upload, + * form_data (and inbox). Used by Add Candidate to warn on reapply. + */ +export function fetchApplicationHistory(email) { + return request('/candidate/applications/fetch', { params: { email } }) +} + /** * Authenticated attachment download. Never send a filesystem path — the server * resolves by owning record + index. `inboxId` is the `inbox` table PK (int), diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 58eba2f..1e4a8c7 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -216,6 +216,8 @@ export function toBoardCard(row, kind = 'inbox') { aiScore: asScore(row.ats_result?.overall_score), recommendation: asText(row.ats_result?.band), applied: toDate(row.created_at), + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } } diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx new file mode 100644 index 0000000..c838e29 --- /dev/null +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -0,0 +1,138 @@ +import { Badge } from '../ui/primitives' +import { STAGE_FROM_STATUS } from '../api/pipeline' +import { fmtDate } from '../lib/format' + +const SOURCE_LABEL = { + inbox: 'Email', + manual: 'Manual', + form: 'Form', + ats: 'ATS', +} + +const STAGE_BADGE = { + Shortlist: 'b-indigo', + Screening: 'b-teal', + Assessment: 'b-purple', + Interview: 'b-amber', + Offer: 'b-green', + Approved: 'b-green', + Hired: 'b-green', + 'On Hold': 'b-amber', + Rejected: 'b-gray', +} + +export function applicationStatusLabel(status) { + if (status == null || status === '') return 'Shortlist' + const key = String(status).toUpperCase() + if (STAGE_FROM_STATUS[key]) return STAGE_FROM_STATUS[key] + const extras = { + UNREAD: 'Unread', + IMPORTED: 'Imported', + PROCESSED: 'Processed', + COMPLETED: 'ATS scored', + FAILED: 'ATS failed', + BANKED: 'CV bank', + } + if (extras[key]) return extras[key] + return key.charAt(0) + key.slice(1).toLowerCase() +} + +export function previousApplicationsOf(row) { + if (!row) return [] + if (Array.isArray(row.previousApplications)) return row.previousApplications + if (Array.isArray(row.previous_applications)) return row.previous_applications + return [] +} + +export function isReapplicant(row) { + if (!row) return false + if (row.isReapplicant === true || row.is_reapplicant === true) return true + return previousApplicationsOf(row).length > 0 +} + +export function previousApplicationsTip(row) { + const items = previousApplicationsOf(row) + if (!items.length) return 'Applied before' + return items.map((item) => { + const job = item.job_title || item.jobTitle || 'Unassigned job' + return `${job} — ${applicationStatusLabel(item.status)}` + }).join('\n') +} + +/** Compact chip for tables, kanban cards, and inbox rows. */ +export function ReappliedBadge({ row, className = '' }) { + if (!isReapplicant(row)) return null + const count = previousApplicationsOf(row).length + return ( + + Reapplied{count > 1 ? ` · ${count}` : ''} + + ) +} + +/** Full prior-job list for profile / inbox / add-candidate. */ +export function PreviousApplications({ row, title = 'Previous applications' }) { + const items = previousApplicationsOf(row) + if (!items.length) return null + return ( +
+
+
+ {title} +
+
+ {items.map((item, idx) => { + const stage = applicationStatusLabel(item.status) + const key = [ + item.source, + item.inbox_id, + item.manual_upload_candidate_id, + item.form_data_id, + item.candidate_id, + item.job_post_id, + idx, + ].filter(Boolean).join(':') + return ( +
+
+
+ {item.job_title || item.jobTitle || 'No job assigned'} +
+
+ {SOURCE_LABEL[item.source] || item.source || 'Application'} + {item.applied_at ? ` · ${fmtDate(item.applied_at)}` : ''} +
+
+ {stage} +
+ ) + })} +
+
+
+ ) +} diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index dcc40de..b906e0f 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -100,6 +100,7 @@ export const qk = { count: (p = {}) => ['candidates', 'count', p], detail: (id) => ['candidates', 'detail', id], history: (id, p = {}) => ['candidates', 'history', id, p], + applications: (email) => ['candidates', 'applications', email], matching: (p = {}) => ['candidates', 'matching', p], matchingDetail: (id) => ['candidates', 'matching', 'detail', id], }, diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index ebad426..42eaa32 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -18,6 +18,7 @@ import * as formsApi from '../api/forms' import * as pipelineApi from '../api/pipeline' import * as s3Api from '../api/s3' import CandidateFormsTab from './CandidateForms' +import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' import { fmtDate, fmtTime, toDate } from '../lib/format' import { companies, moneyK, pick } from '../data/seed' @@ -274,7 +275,10 @@ export default function CandidateProfile({
-
{live?.name || c.name}
+
+ {live?.name || c.name} + +
{company ? `${title} at ${company}` : title}
{stageLabel && {stageLabel}}{' '} @@ -322,6 +326,7 @@ export default function CandidateProfile({
{tab === 'Overview' && (guard || (live ? ( <> +
diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index b89b39b..f2fe9de 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -31,6 +31,7 @@ import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' +import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' import { useFormState } from '../components/AuthLayout' import { persist, useSeedMutation } from '../data/seedQueries' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' @@ -201,7 +202,10 @@ function HiringManagerCandidates() { sortValue: (r) => r.name || '', render: (r) => ( <> -
{r.name || '—'}
+
+ {r.name || '—'} + +
{r.email || '—'}
), @@ -658,6 +662,7 @@ function RecruiterCandidates() { {c.source === 'Form' && ( Form )} +
{c.email || '—'}
@@ -1015,6 +1020,30 @@ function AddCandidate({ onClose, onSave, onInvalid }) { referral: '', }) + const lookupEmail = (form.values.email || '').trim().toLowerCase() + const [debouncedEmail, setDebouncedEmail] = useState('') + useEffect(() => { + const timer = setTimeout(() => setDebouncedEmail(lookupEmail), 400) + return () => clearTimeout(timer) + }, [lookupEmail]) + const emailLooksValid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(debouncedEmail) + const priorQuery = useQuery({ + queryKey: qk.candidates.applications(debouncedEmail), + queryFn: async () => { + const res = await candidatesApi.fetchApplicationHistory(debouncedEmail) + return res?.data ?? null + }, + enabled: emailLooksValid, + staleTime: 30_000, + }) + const priorHistory = priorQuery.data + const priorRow = priorHistory?.found + ? { + is_reapplicant: Boolean(priorHistory.is_reapplicant), + previous_applications: Array.isArray(priorHistory.applications) ? priorHistory.applications : [], + } + : null + // Defaulting by derivation rather than in an effect: the picker resolves after // first paint, and useFormState's setters are new every render, so seeding the // field from an effect would either loop or need a ref to guard it. @@ -1127,6 +1156,25 @@ function AddCandidate({ onClose, onSave, onInvalid }) { } >
{ e.preventDefault(); submit() }}> + {priorHistory?.found && priorRow?.previous_applications.length > 0 && ( + + )} + {priorHistory?.found && !(priorRow?.previous_applications.length) && ( +
+
+ This email already has a candidate account + {priorHistory.user?.name ? ` (${priorHistory.user.name})` : ''}. +
+
+ )}
diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 2dc6022..e5d56ba 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -27,6 +27,7 @@ import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { exportStyledXlsx } from '../lib/exportXlsx' import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate } from '../lib/format' +import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' import * as sheetApi from '../api/sheet' @@ -260,6 +261,8 @@ function mapFormRow(row) { atsScore, assignedId, assignedPost: row.assigned_job_post || null, + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } } @@ -374,6 +377,8 @@ async function fetchMessageDetail(recordId) { suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedPost: row.assigned_job_post || null, + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } } @@ -422,6 +427,8 @@ async function fetchApplications(params) { ? row.suggested_job_post_ids.map(String) : [], assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } }), total: Number(res?.total ?? rows.length) || 0, @@ -1363,6 +1370,7 @@ export default function Inbox() { {i.duplicate && ( DUP )} +
{i.position}
@@ -1633,7 +1641,10 @@ function FormApplicantDetail({
-
{i.name}
+
+ {i.name} + +
{i.position}
{i.processing}{' '} @@ -1662,6 +1673,8 @@ function FormApplicantDetail({ )}
+ + {(resumeHref || profileHref) && (
{resumeHref && ( @@ -2003,7 +2016,10 @@ function ApplicationDetail({
-
{i.name}
+
+ {i.name} + +
{i.position}
{i.processing}{' '} @@ -2027,6 +2043,8 @@ function ApplicationDetail({ )}
+ + {(resumeKey || i.hasAttachment || profileHref) && (
{(resumeKey || i.hasAttachment) && ( diff --git a/frontend/src/screens/JobCandidates.jsx b/frontend/src/screens/JobCandidates.jsx index fdcfbe4..b3ae014 100644 --- a/frontend/src/screens/JobCandidates.jsx +++ b/frontend/src/screens/JobCandidates.jsx @@ -19,6 +19,7 @@ import { Avatar, Badge, EmptyState, Icon, ProgressBar } from '../ui/primitives' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' +import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed' const SOURCE_LABEL = { upload: 'Upload', inbox: 'Email' } @@ -75,7 +76,10 @@ function CandidateCard({ c, onView }) {
-
{displayName(c.name)}
+
+ {displayName(c.name)} + +
{c.currentTitle ?? '—'}
@@ -147,7 +151,10 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
-
{displayName(c.name)}
+
+ {displayName(c.name)} + +
{roleLine}
{SOURCE_LABEL[c.source] ?? c.source ?? '—'} @@ -172,6 +179,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
{tab === 'Overview' && ( <> +
Candidate
{displayName(c.name)}
Current Title
{c.currentTitle ?? '—'}
diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 72a5af1..9e257d7 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -26,6 +26,7 @@ import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' +import { ReappliedBadge } from '../components/ReapplicantHistory' /* Stage colours reference CSS tokens so the board re-tints with the theme. */ export const KANBAN_STAGES = [ @@ -286,6 +287,7 @@ export default function Pipeline() { {c.source === 'Form' && ( Form )} +
{c.currentTitle}
diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index a25f1df..4a3e01a 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -1,7 +1,7 @@ /* ============================================================ Settings — org settings tabs persist via GET/PUT /org-settings/*. - Excluding Universities / Companies are CRUD lists on Exclude_University - and Exclude_Company (not org_settings key/value). Users + Appearance stay + Excluding Universities / Companies are CRUD lists on exclude_university + and exclude_company (not org_settings key/value). Users + Appearance stay as before. Email Templates stay decorative (explicitly out of Section C wiring scope). The Permissions tab remains chrome; Access Control is the authoritative RBAC surface. @@ -37,7 +37,11 @@ function humaniseSlug(slug) { const TABS = [ 'General', 'Users', 'Approvals', 'Permissions', 'Notifications', 'Email Templates', 'Career Portal', 'Branding', 'Security', 'Appearance', - 'Excluding Universities', 'Excluding Companies', +] + +const EXCLUSION_TABS = [ + 'Excluding Universities', + 'Excluding Companies', ] const ORG_TABS = new Set(['General', 'Notifications', 'Career Portal', 'Branding', 'Security']) @@ -140,16 +144,24 @@ export default function Settings() { )} /> - ({ - key: t, - label: t, - count: t === 'Approvals' && pendingCount ? pendingCount : undefined, - }))} - /> +
+ ({ + key: t, + label: t, + count: t === 'Approvals' && pendingCount ? pendingCount : undefined, + }))} + /> + ({ key: t, label: t }))} + /> +
{tab === 'General' && { saveRef.current = fn }} />} diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 9daef9b..58649b2 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -46,6 +46,7 @@ import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' +import { ReappliedBadge } from '../components/ReapplicantHistory' import { fmtDate } from '../lib/format' import { avatarColor, initials as initialsOf } from '../data/seed' @@ -121,6 +122,8 @@ function merge(row, template) { // a recruiter would read as a real match. aiScore: row.ai_score ?? null, recommendation: row.recommendation ?? null, + isReapplicant: Boolean(row.is_reapplicant), + previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], } } @@ -353,7 +356,10 @@ export default function TalentPool() {
-
{c.name}
+
+ {c.name} + +
{c.currentTitle}
{c.aiScore != null && } diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 36a4365..5baa629 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1798,6 +1798,23 @@ canvas { width: 100%; max-width: 100%; display: block; } ten tabs do not fit the 860px profile modal. */ .tabs-wrap { flex-wrap: wrap; overflow-x: visible; } +/* Settings: keep exclusion tabs on their own full-width bar, grouped left + with a wider hit area so they no longer wrap as a leftover second row. */ +.settings-tabs { margin-bottom: var(--space-5); } +.settings-tabs-primary { margin-bottom: 0; } +.settings-tabs-exclude { + margin-bottom: 0; + justify-content: flex-start; + flex-wrap: nowrap; + overflow-x: auto; +} +.settings-tabs-exclude .tab { + min-width: 240px; + justify-content: center; + padding-left: 28px; + padding-right: 28px; +} + /* Full-page candidate profile (/candidate/:userId). The page centers itself with a generous cap so ultrawide monitors don't get a mile-wide form, and everything below the cap is fluid — no fixed widths. */