Implement application history retrieval for candidates and enhance reapplication tracking #71

Merged
ahmed.mujtaba merged 2 commits from SQS_BROKER into main 2026-09-04 14:08:39 +00:00
25 changed files with 707 additions and 39 deletions

View File

@ -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

View File

@ -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,

View File

@ -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).

View File

@ -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."""

View File

@ -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)

View File

@ -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(...),

View File

@ -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

View File

@ -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,
}

View File

@ -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

View File

@ -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),

View File

@ -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)

View File

@ -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):

View File

@ -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 = (

View File

@ -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),

View File

@ -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 : [],
}
}

View File

@ -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 (
<span
className={`badge b-amber badge-plain ${className}`.trim()}
style={{ marginLeft: 6, fontSize: 10, padding: '1px 6px' }}
title={previousApplicationsTip(row)}
>
Reapplied{count > 1 ? ` · ${count}` : ''}
</span>
)
}
/** 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 (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--warning-soft)',
border: '1px solid var(--warning)',
marginBottom: 18,
}}
>
<div className="card-body">
<div
style={{
fontSize: 12,
color: 'var(--warning)',
fontWeight: 700,
textTransform: 'uppercase',
marginBottom: 10,
}}
>
{title}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{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 (
<div
key={key}
className="flex items-center gap-8"
style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}
>
<div style={{ minWidth: 0 }}>
<div className="fw-600" style={{ fontSize: 13 }}>
{item.job_title || item.jobTitle || 'No job assigned'}
</div>
<div className="cell-sub">
{SOURCE_LABEL[item.source] || item.source || 'Application'}
{item.applied_at ? ` · ${fmtDate(item.applied_at)}` : ''}
</div>
</div>
<Badge className={STAGE_BADGE[stage] || 'b-gray'}>{stage}</Badge>
</div>
)
})}
</div>
</div>
</div>
)
}

View File

@ -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],
},

View File

@ -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({
<div className="profile-hero">
<Avatar name={live?.name || c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{live?.name || c.name}</div>
<div className="ph-name">
{live?.name || c.name}
<ReappliedBadge row={live || c} />
</div>
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
<div className="ph-tags">
{stageLabel && <Badge>{stageLabel}</Badge>}{' '}
@ -322,6 +326,7 @@ export default function CandidateProfile({
<div className="tab-pane active">
{tab === 'Overview' && (guard || (live ? (
<>
<PreviousApplications row={live} />
<div className="info-grid" style={{ marginBottom: 20 }}>
<Info label="Email" val={live.email} />
<Info label="Phone" val={live.phone} />

View File

@ -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) => (
<>
<div className="cell-primary">{r.name || '—'}</div>
<div className="cell-primary">
{r.name || '—'}
<ReappliedBadge row={r} />
</div>
<div className="cell-sub">{r.email || '—'}</div>
</>
),
@ -658,6 +662,7 @@ function RecruiterCandidates() {
{c.source === 'Form' && (
<Badge className="b-gray" style={{ marginLeft: 6, fontSize: 10 }}>Form</Badge>
)}
<ReappliedBadge row={c} />
</div>
<div className="cell-sub">{c.email || '—'}</div>
</div>
@ -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 }) {
}
>
<form noValidate onSubmit={(e) => { e.preventDefault(); submit() }}>
{priorHistory?.found && priorRow?.previous_applications.length > 0 && (
<PreviousApplications row={priorRow} title="This email already applied" />
)}
{priorHistory?.found && !(priorRow?.previous_applications.length) && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--warning-soft)',
border: '1px solid var(--warning)',
marginBottom: 18,
}}
>
<div className="card-body text-sm">
This email already has a candidate account
{priorHistory.user?.name ? ` (${priorHistory.user.name})` : ''}.
</div>
</div>
)}
<div className="form-grid">
<div className="form-field">
<label>Full Name <span className="req">*</span></label>

View File

@ -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 && (
<span className="badge b-red badge-plain" style={{ padding: '1px 6px', fontSize: 10 }}>DUP</span>
)}
<ReappliedBadge row={i} />
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-aside">
@ -1633,7 +1641,10 @@ function FormApplicantDetail({
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-name" style={{ fontSize: 19 }}>
{i.name}
<ReappliedBadge row={i} />
</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
@ -1662,6 +1673,8 @@ function FormApplicantDetail({
)}
</div>
<PreviousApplications row={i} />
{(resumeHref || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{resumeHref && (
@ -2003,7 +2016,10 @@ function ApplicationDetail({
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-name" style={{ fontSize: 19 }}>
{i.name}
<ReappliedBadge row={i} />
</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
@ -2027,6 +2043,8 @@ function ApplicationDetail({
)}
</div>
<PreviousApplications row={i} />
{(resumeKey || i.hasAttachment || profileHref) && (
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
{(resumeKey || i.hasAttachment) && (

View File

@ -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 }) {
<div className="cand-head">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="cand-id">
<div className="cand-name">{displayName(c.name)}</div>
<div className="cand-name">
{displayName(c.name)}
<ReappliedBadge row={c} />
</div>
<div className="cand-role">{c.currentTitle ?? '—'}</div>
</div>
<MiniRing score={c.aiScore} />
@ -147,7 +151,10 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
<div className="profile-hero">
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
<div className="flex-1">
<div className="ph-name">{displayName(c.name)}</div>
<div className="ph-name">
{displayName(c.name)}
<ReappliedBadge row={c} />
</div>
<div className="ph-role">{roleLine}</div>
<div className="ph-tags">
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source ?? '—'}</Badge>
@ -172,6 +179,7 @@ export function ScoredCandidateDetail({ candidate: c, jobTitle, onClose }) {
<div className="tab-pane active">
{tab === 'Overview' && (
<>
<PreviousApplications row={c} />
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Candidate</div><div className="iv">{displayName(c.name)}</div></div>
<div className="info-item"><div className="il">Current Title</div><div className="iv">{c.currentTitle ?? '—'}</div></div>

View File

@ -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' && (
<Badge className="b-gray" style={{ fontSize: 10 }}>Form</Badge>
)}
<ReappliedBadge row={c} />
</div>
<div className="kc-role">{c.currentTitle}</div>
</div>

View File

@ -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() {
)}
/>
<Tabs
value={tab}
onChange={setTab}
className="tabs tabs-wrap"
tabs={TABS.map((t) => ({
key: t,
label: t,
count: t === 'Approvals' && pendingCount ? pendingCount : undefined,
}))}
/>
<div className="settings-tabs">
<Tabs
value={tab}
onChange={setTab}
className="tabs settings-tabs-primary"
tabs={TABS.map((t) => ({
key: t,
label: t,
count: t === 'Approvals' && pendingCount ? pendingCount : undefined,
}))}
/>
<Tabs
value={tab}
onChange={setTab}
className="tabs settings-tabs-exclude"
tabs={EXCLUSION_TABS.map((t) => ({ key: t, label: t }))}
/>
</div>
<div className="tab-pane active">
{tab === 'General' && <General registerSave={(fn) => { saveRef.current = fn }} />}

View File

@ -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() {
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="lr-title">{c.name}</div>
<div className="lr-title">
{c.name}
<ReappliedBadge row={c} />
</div>
<div className="lr-sub">{c.currentTitle}</div>
</div>
{c.aiScore != null && <ScoreChip score={c.aiScore} />}

View File

@ -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. */