. #15

Merged
ahmed.mujtaba merged 4 commits from RecruiterHub into main 2026-08-13 13:37:35 +00:00
17 changed files with 1102 additions and 469 deletions

View File

@ -1,5 +1,6 @@
import logging
import os
from shlex import join
import uuid
from datetime import datetime, timezone
from typing import Any, List, Optional
@ -46,23 +47,14 @@ class Inbox(SQLModel, table=True):
message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
messages: Optional["Inbox_Messages"] = Relationship(back_populates="inbox")
# tz-AWARE, matching every other timestamp the analytics layer filters on.
# A naive column here made asyncpg reject the aware UTC bounds that
# analytics/views.py builds, so /analytics/hiring-trend and /analytics/kpis
# both 500'd before the query ever reached Postgres.
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
favorite: Optional[bool] = Field(default=False)
rating: Optional[float] = Field(default=0.0)
# The CURRENT ats_results row for this application. Written together with the
# supersede chain in CandidateScoring._sync_inbox_ats: every completed inbox
# score inserts an ats_results row and repoints this at it, so the score is
# one direct id join away instead of a filter on ats_results.is_current.
ats_id: uuid.UUID | None = Field(default=None, foreign_key="ats_results.id")
# selectin on one-to-many: joined would repeat the inbox row per child
interviews: List["Interviews"] = Relationship(
back_populates="inbox",
sa_relationship_kwargs={"lazy": "selectin"},
@ -80,6 +72,107 @@ class Inbox(SQLModel, table=True):
sa_relationship_kwargs={"lazy": "joined"},
)
@classmethod
async def get_all(cls,session:AsyncSession,job_post_id=None,limit=None,offset=0):
try:
from job.job_post.models import JobPosts
qry=(
select(
cls.id.label("inbox_id"),
cls.user_id,
Users.name,
Users.email,
Inbox_Messages.candidate_phone_number.label("phone"),
Inbox_Messages.assigned_job_post_id,
Inbox_Messages.application_status,
Inbox_Messages.current_employment,
Inbox_Messages.current_title,
Inbox_Messages.experience,
cls.created_at,
JobPosts.title,
AtsResults.id.label("ats_result_id"),
AtsResults.overall_score,
AtsResults.band,
AtsResults.job_post_id.label("ats_job_post_id"),
AtsResults.computed_at,
AtsResults.candidate_id,
AtsResults.user_id.label("ats_user_id"),
)
.join(Users,cls.user_id==Users.id)
.join(Roles,Users.role_id==Roles.id)
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
.outerjoin(AtsResults,cls.ats_id==AtsResults.id)
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
.order_by(cls.created_at.desc())
)
if job_post_id:
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
if limit is not None:
qry=qry.limit(limit).offset(offset)
result=await session.execute(qry)
rows=[]
for row in result.mappings().all():
ats=None
if row["ats_result_id"] is not None:
ats={
"id":str(row["ats_result_id"]),
"overall_score":row["overall_score"],
"band":row["band"] or None,
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
"candidate_id":str(row["candidate_id"]) if row["candidate_id"] else None,
"user_id":str(row["ats_user_id"]) if row["ats_user_id"] else None,
}
status=row["application_status"]
rows.append({
"inbox_id":row["inbox_id"],
"user_id":str(row["user_id"]) if row["user_id"] else None,
"name":row["name"],
"email":row["email"],
"application_status":status.value if status else None,
"phone":row["phone"],
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
"title":row["title"] or None,
"current_employment":row["current_employment"] or None,
"current_title":row["current_title"] or None,
"experience":row["experience"] or None,
"created_at":row["created_at"].isoformat() if row["created_at"] else None,
"ats_result":ats,
})
return rows
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def count_by_status(cls,session:AsyncSession,job_post_id=None):
try:
from job.job_post.models import JobPosts
qry=(
select(Inbox_Messages.application_status,func.count())
.select_from(cls)
.join(Users,cls.user_id==Users.id)
.join(Roles,Users.role_id==Roles.id)
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
.join(JobPosts,Inbox_Messages.assigned_job_post_id==JobPosts.id)
.where(Inbox_Messages.assigned_job_post_id.is_not(None))
.where(Roles.role_name==EnumRoles.CANDIDATE.value)
.group_by(Inbox_Messages.application_status)
)
if job_post_id:
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
result=await session.execute(qry)
counts={}
for status,n in result.all():
key=status.value if hasattr(status,"value") else (str(status) if status else None)
if not key:
continue
counts[key]=int(n or 0)
return counts
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
def _candidate_search_filter(cls, search: str):
pattern = f"%{search}%"
@ -507,6 +600,18 @@ class Inbox_Messages(SQLModel, table=True):
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
@classmethod
async def set_ats_score(cls, session: AsyncSession, record_id, score, band):
row = await cls.get_inbox_message_by_id(session, record_id)
if not row:
return None
row.ats_score = float(score)
row.ats_band = band or ""
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def set_assigned_job_post(cls, session: AsyncSession, record_id, job_post_id):
"""Set or clear assigned_job_post_id; returns the row or None if missing."""
@ -608,9 +713,11 @@ class AtsResults(SQLModel, table=True):
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
# Inbox applications link here; NULL for upload-sourced scores, which have no
# inbox row. candidate_id links every score to the candidates row it scored.
# inbox row. Identity is XOR: matching users.email -> user_id (candidate_id
# NULL); otherwise candidate_id (user_id NULL). Blank email is the latter.
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
candidate_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="candidates.id")
user_id: uuid.UUID | None = Field(default=None, index=True, foreign_key="users.id")
job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id")
overall_score: float = Field(default=0.0)
band: str = Field(default="")
@ -641,6 +748,41 @@ class AtsResults(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def get_for_inbox_job(cls, session: AsyncSession, inbox_id, job_post_id):
"""Any score for this application against this job — current or superseded.
Inbox-side idempotency is (inbox_id, job_post_id) only. candidate_id may
be NULL when the CV email matched a user; do not key off it here.
"""
jid = cls._as_uuid(job_post_id)
if jid is None:
return None
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id), cls.job_post_id == jid)
.order_by(cls.computed_at.desc())
)
return result.scalars().first()
@classmethod
async def resolve_identity(cls, session: AsyncSession, email, candidate_id):
"""XOR identity for a score row from the scored candidate's email.
Matching users.email (case-insensitive) -> user_id, candidate_id NULL.
Missing/blank email or no user -> candidate_id, user_id NULL.
"""
normalized = (email or "").strip().lower()
if normalized:
user_id = (
await session.execute(
select(Users.id).where(func.lower(Users.email) == normalized)
)
).scalar_one_or_none()
if user_id is not None:
return {"candidate_id": None, "user_id": user_id}
return {"candidate_id": candidate_id, "user_id": None}
@classmethod
async def get_current_for_candidate(cls, session: AsyncSession, candidate_id):
"""Current row for an upload-sourced score, chained per candidates row —
@ -653,9 +795,52 @@ class AtsResults(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def get_current_for_user(cls, session: AsyncSession, user_id, job_post_id=None):
"""Current upload-sourced score for a matched user, scoped per job."""
uid = cls._as_uuid(user_id)
if uid is None:
return None
qry = select(cls).where(cls.user_id == uid, cls.is_current == True) # noqa: E712
jid = cls._as_uuid(job_post_id) if job_post_id is not None else None
if jid is not None:
qry = qry.where(cls.job_post_id == jid)
result = await session.execute(qry.order_by(cls.computed_at.desc()))
return result.scalars().first()
@classmethod
async def insert_result(cls, session: AsyncSession, fields: dict):
"""Insert a score row and supersede the previous current one.
Inbox scores chain on inbox_id and repoint inbox.ats_id (candidate_id
may be NULL). Upload scores chain on candidate_id, or on (user_id,
job_post_id) when identity resolved to a user. Flush the INSERT first:
with no relationship() edge the unit of work emits the UPDATEs first,
and the FKs reject a pointer to a row not yet inserted."""
inbox_id = fields.get("inbox_id")
candidate_id = fields.get("candidate_id")
user_id = fields.get("user_id")
job_post_id = fields.get("job_post_id")
if inbox_id is not None:
prev = await cls.get_current_for_inbox(session, inbox_id)
elif candidate_id is not None:
prev = await cls.get_current_for_candidate(session, candidate_id)
elif user_id is not None:
prev = await cls.get_current_for_user(session, user_id, job_post_id)
else:
prev = None
row = cls(**fields)
session.add(row)
await session.flush()
if prev is not None:
prev.is_current = False
prev.superseded_by_id = row.id
session.add(prev)
if inbox_id is not None:
link = await Inbox.get_inbox_by_id(session, inbox_id)
if link is not None:
link.ats_id = row.id
link.updated_at = _now()
session.add(link)
await session.commit()
return row

View File

@ -5,13 +5,17 @@ from __future__ import annotations
import base64
import os
import re
import uuid
from pathlib import Path
from urllib.parse import quote
import httpx
from dotenv import load_dotenv
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
from inbox.models import Inbox_Messages
from inbox.models import AtsResults, Inbox, Inbox_Messages
from job.candidate.models import Candidates, Manual_UPLOAD_CANDIDATE
from job.candidate.views import FileRead
load_dotenv()
@ -161,3 +165,128 @@ async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]:
if not texts:
return "","; ".join(errors) if errors else "no text extracted from PDF"
return "\n\n---\n\n".join(texts),""
def _ats_score_payload(row):
if row is None:
return None
return {
"overall_score":row.overall_score,
"band":row.band or None,
"job_post_id":str(row.job_post_id) if row.job_post_id else None,
"computed_at":row.computed_at.isoformat() if row.computed_at else None,
"candidate_id":str(row.candidate_id) if row.candidate_id else None,
"user_id":str(row.user_id) if row.user_id else None,
}
async def get_ats_score_for_user(session:AsyncSession,user_id,job_post_id=None):
"""Current ats_results overall score for a candidate -> dict, or None.
Prefer ats_results.user_id (CV email matched that user). Fall back to the
inbox.user_id join for scores whose email did not match any user, where
candidate_id is set and user_id is NULL.
Pass job_post_id to pin one application when a candidate has several;
without it the newest current score across their applications wins.
"""
try:
uid=uuid.UUID(str(user_id))
except (TypeError,ValueError):
return None
direct=(
select(AtsResults)
.where(AtsResults.user_id==uid,AtsResults.is_current==True) # noqa: E712
.order_by(AtsResults.computed_at.desc())
)
if job_post_id:
try:
direct=direct.where(AtsResults.job_post_id==uuid.UUID(str(job_post_id)))
except (TypeError,ValueError):
return None
row=(await session.execute(direct)).scalars().first()
if row is not None:
return _ats_score_payload(row)
qry=(
select(AtsResults)
.join(Inbox,AtsResults.inbox_id==Inbox.id)
.join(Inbox_Messages,Inbox.message_id==Inbox_Messages.id)
.where(
Inbox.user_id==uid,
Inbox_Messages.assigned_job_post_id.is_not(None),
AtsResults.job_post_id==Inbox_Messages.assigned_job_post_id,
AtsResults.is_current==True, # noqa: E712
)
.order_by(AtsResults.computed_at.desc())
)
if job_post_id:
try:
qry=qry.where(Inbox_Messages.assigned_job_post_id==uuid.UUID(str(job_post_id)))
except (TypeError,ValueError):
return None
return _ats_score_payload((await session.execute(qry)).scalars().first())
async def get_ats_score_for_manual_user(session:AsyncSession,user_id,job_post_id=None):
"""Current ats_results overall score for an Add Candidate user -> dict, or None.
Prefer ats_results.user_id (Add Candidate always creates a users row, so a
later score against that email lands on user_id). Fall back to the
email+candidate_id join for rows written before user_id existed.
apply_via=manual_upload is the Add Candidate gate; /import never writes
that table.
Pass job_post_id to pin one application when a candidate has several;
without it the newest current score across their applications wins.
"""
try:
uid=uuid.UUID(str(user_id))
except (TypeError,ValueError):
return None
direct=(
select(AtsResults)
.where(AtsResults.user_id==uid,AtsResults.is_current==True) # noqa: E712
.order_by(AtsResults.computed_at.desc())
)
if job_post_id:
try:
jid=uuid.UUID(str(job_post_id))
except (TypeError,ValueError):
return None
direct=direct.where(AtsResults.job_post_id==jid)
row=(await session.execute(direct)).scalars().first()
if row is not None:
return _ats_score_payload(row)
qry=(
select(AtsResults)
.join(Candidates,AtsResults.candidate_id==Candidates.id)
.join(
Manual_UPLOAD_CANDIDATE,
(Candidates.job_id==Manual_UPLOAD_CANDIDATE.job_post_id)
&(Candidates.candidate_email==Manual_UPLOAD_CANDIDATE.candidate_email),
)
.where(
Manual_UPLOAD_CANDIDATE.user_id==uid,
Manual_UPLOAD_CANDIDATE.apply_via=="manual_upload",
Candidates.status=="completed",
AtsResults.job_post_id==Manual_UPLOAD_CANDIDATE.job_post_id,
AtsResults.is_current==True, # noqa: E712
)
.order_by(AtsResults.computed_at.desc())
)
if job_post_id:
try:
jid=uuid.UUID(str(job_post_id))
except (TypeError,ValueError):
return None
qry=qry.where(
Manual_UPLOAD_CANDIDATE.job_post_id==jid,
AtsResults.job_post_id==jid,
)
return _ats_score_payload((await session.execute(qry)).scalars().first())

View File

@ -3,15 +3,15 @@
from __future__ import annotations
import logging
import uuid
from datetime import datetime,timezone
from fastapi import HTTPException
from sqlalchemy import select
from agent.execute_agent import run_agent
from db_setup import session_scope
from employment_agent.execute_agent import run_employment_agent
from inbox.models import Inbox_Messages
from inbox.models import Inbox_Messages,Inbox,AtsResults
from inbox.plugins import extract_phone,extract_resume_text
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
@ -26,28 +26,26 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
"""ATS-score one inbox CV against one job post — the no-upload path.
The decoded attachment already on disk is the CV; the job post in the
database is the JD. Idempotent: a (message, job) pair with a completed
score is never paid for twice; re-runs are a no-op.
database is the JD. Idempotent: a (message, job) pair with an ats_results
row is never paid for twice; re-runs are a no-op.
"""
# Lazy imports: inbox.plugins imports job.candidate.views, so a top-level
# import here would be circular.
from job.candidate.models import Candidates
from job.candidate.views import CandidateScoring
mid=Candidates._as_uuid(record_id)
jid=Candidates._as_uuid(job_id)
if mid is None or jid is None:
try:
mid=uuid.UUID(str(record_id))
jid=uuid.UUID(str(job_id))
except ValueError:
raise PermanentTaskError("record_id and job_id must be uuids")
async with session_scope() as session:
existing=await session.execute(
select(Candidates).where(
Candidates.inbox_message_id==mid,
Candidates.job_id==jid,
Candidates.status=="completed",
)
)
if existing.scalars().first() is not None:
link=await Inbox.get_inbox_by_message_id(session,mid)
if link is not None:
# (inbox, job) only — candidate_id is NULL when the CV email matched
# a user, so already_scored must not depend on it.
existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
if existing is not None:
return {"status":"already_scored"}
job=await JobPosts.get_job_post_by_id(session,job_id)
if job is None or job.is_deleted:

View File

@ -21,7 +21,8 @@ from dotenv import load_dotenv
from datetime import datetime, time, timezone
from pydantic import BaseModel
from uuid import UUID
from typing import Optional
import uuid
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@ -89,7 +90,8 @@ class FeedbackUpdate(BaseModel):
class StageChange(BaseModel):
inbox_id: int
inbox_id: int | None = None
manual_upload_id: UUID | None = None
to_stage: str
change_reason: str | None = None
@ -666,7 +668,8 @@ async def change_candidate_stage(
try:
service=Pipeline(session=session)
data=await service.change_stage(
payload.inbox_id,payload.to_stage,current_user,change_reason=payload.change_reason,
payload.to_stage,current_user,inbox_id=payload.inbox_id,
manual_upload_id=payload.manual_upload_id,change_reason=payload.change_reason,
)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
@ -674,17 +677,52 @@ async def change_candidate_stage(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/candidates/fetch")
async def fetch_pipeline_candidates(
job_post_id:Optional[uuid.UUID]=Query(None),
limit:int=Query(200,ge=1,le=1000),
offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset)
return JSONResponse(content={**result,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/candidate/score/fetch")
async def fetch_pipeline_candidate_score(
user_id:uuid.UUID=Query(...),
job_post_id:uuid.UUID=Query(...),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.get_pipeline_candidates(user_id=user_id,job_post_id=job_post_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/pipeline/transitions/fetch")
async def fetch_pipeline_transitions(
transition_id:str=Query(None),
inbox_id:int=Query(None),
manual_upload_id:UUID=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Pipeline(session=session)
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id)
data=await service.get_transitions(inbox_id=inbox_id,transition_id=transition_id,manual_upload_id=manual_upload_id)
total=1 if isinstance(data,dict) else len(data)
return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException:

View File

@ -2,6 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
from fastapi import HTTPException
from sqlalchemy import JSON, DateTime, func, UniqueConstraint
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -35,33 +36,122 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
# 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": ""})
# Add Candidate only (POST /candidate/create/candidate). /import never writes this table.
apply_via:Optional[str]=Field(nullable=True)
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")
experience: str = Field(default="")
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied.
status: str = Field(default="")
# Free text, not a users FK: a referrer is often someone outside the system
# (a client, a former colleague), and recruiters type whatever the candidate
# told them. "" rather than NULL keeps it consistent with the columns above.
#
# server_default is load-bearing and NOT decoration, unlike the columns above
# — they arrived with the CREATE TABLE, this one arrives as an ALTER. The
# startup autogenerate would emit `ADD COLUMN referral_by VARCHAR NOT NULL`,
# which Postgres rejects outright on a table that already holds rows. The
# DEFAULT backfills them. Pass the bare "" — SQLAlchemy quotes a plain string
# into DEFAULT '', whereas "''" would render DEFAULT '''''' instead.
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})
# The CV as uploaded: file_name is the recruiter-facing original, file_path
# the absolute location under inbox/decoded_attachments. They differ on
# purpose — the stored basename is uniquified so two candidates uploading
# "resume.pdf" cannot overwrite one another (see FileRead.save_manual_upload).
# Same ALTER-on-a-populated-table reasoning as referral_by above, so both
# carry a server default.
file_name: str = Field(default="", sa_column_kwargs={"server_default": ""})
file_path: str = Field(default="", sa_column_kwargs={"server_default": ""})
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0):
try:
from inbox.models import AtsResults
from users.models import Users
from job.job_post.models import JobPosts
qry=(
select(
cls.id,
cls.candidate_email,
cls.user_id,
Users.email,
cls.job_post_id,
Users.name,
cls.candidate_phone,
JobPosts.title,
cls.status,
cls.current_company,
cls.current_position,
cls.experience,
cls.created_at,
cls.updated_at,
AtsResults.id.label("ats_result_id"),
AtsResults.overall_score,
AtsResults.band,
AtsResults.job_post_id.label("ats_job_post_id"),
AtsResults.computed_at,
AtsResults.candidate_id,
AtsResults.user_id.label("ats_user_id"),
)
.join(Users,cls.user_id==Users.id)
.join(JobPosts,cls.job_post_id==JobPosts.id)
.outerjoin(
AtsResults,
(Users.id==AtsResults.user_id)
&(AtsResults.job_post_id==cls.job_post_id)
&(AtsResults.is_current==True), # noqa: E712
)
.order_by(cls.created_at.desc())
)
if job_post_id:
qry=qry.where(cls.job_post_id==job_post_id)
if limit is not None:
qry=qry.limit(limit).offset(offset)
result=await session.execute(qry)
rows=[]
for row in result.mappings().all():
ats=None
if row["ats_result_id"] is not None:
ats={
"id":str(row["ats_result_id"]),
"overall_score":row["overall_score"],
"band":row["band"] or None,
"job_post_id":str(row["ats_job_post_id"]) if row["ats_job_post_id"] else None,
"computed_at":row["computed_at"].isoformat() if row["computed_at"] else None,
"candidate_id":str(row["candidate_id"]) if row["candidate_id"] else None,
"user_id":str(row["ats_user_id"]) if row["ats_user_id"] else None,
}
rows.append({
"id":str(row["id"]) if row["id"] else None,
"candidate_email":row["candidate_email"],
"user_id":str(row["user_id"]) if row["user_id"] else None,
"email":row["email"],
"job_post_id":str(row["job_post_id"]) if row["job_post_id"] else None,
"name":row["name"],
"candidate_phone":row["candidate_phone"],
"title":row["title"] or None,
"application_status":row["status"] or None,
"current_company":row["current_company"] or None,
"current_position":row["current_position"] or None,
"experience":row["experience"] or None,
"created_at":row["created_at"].isoformat() if row["created_at"] else None,
"updated_at":row["updated_at"].isoformat() if row["updated_at"] else None,
"ats_result":ats,
})
return rows
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def count_by_status(cls, session: AsyncSession, job_post_id=None):
try:
from users.models import Users
from job.job_post.models import JobPosts
qry=(
select(cls.status,func.count())
.select_from(cls)
.join(Users,cls.user_id==Users.id)
.join(JobPosts,cls.job_post_id==JobPosts.id)
.group_by(cls.status)
)
if job_post_id:
qry=qry.where(cls.job_post_id==job_post_id)
result=await session.execute(qry)
counts={}
for status,n in result.all():
key=(status or "").strip() or "UNKNOWN"
counts[key]=counts.get(key,0)+int(n or 0)
return counts
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
if record_id in (None, ""):
@ -103,11 +193,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
full_text=fields.get("full_text") or "",
current_company=(fields.get("current_company") or "").strip(),
current_position=(fields.get("current_position") or "").strip(),
apply_via="manual_upload",
user_id=user.id,
platform=(fields.get("platform") or "").strip(),
created_by=cls._as_uuid(fields.get("created_by")),
experience=(fields.get("experience") or "").strip(),
status=(fields.get("status") or "").strip(),
status=(fields.get("status") or "").strip() or "PENDING",
referral_by=(fields.get("referral_by") or "").strip(),
file_name=(fields.get("file_name") or "").strip(),
file_path=(fields.get("file_path") or "").strip(),
@ -127,29 +218,28 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def get_by_id(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return None
result = await session.execute(select(cls).where(cls.id == uid))
return result.scalars().first()
class Candidates(SQLModel, table=True):
"""One scored (or failed-to-score) CV against one job post.
The dedupe key is (job_id, content_sha256), not the filename: inbox attachments
are stored by basename so different candidates can collide on "resume.pdf", while
identical bytes can arrive via both upload and email. Re-scoring the same bytes
against the same job updates the existing row (fresh model output, updated_at
bumped) instead of duplicating it. content_sha256 is NULL when the file bytes
were never readable (missing on disk); NULLs never conflict in the unique index.
"""
__tablename__ = "candidates"
__table_args__ = (UniqueConstraint("job_id", "content_sha256"),)
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
job_id: uuid.UUID = Field(foreign_key="job_posts.id", index=True)
source: str = Field(default="upload") # "upload" | "inbox"
inbox_message_id: uuid.UUID | None = Field(default=None, foreign_key="inbox_messages.id")
source: str = Field(default="upload") # "upload" | "inbox" origin metadata, not an FK
filename: str
file_path: str | None = Field(default=None) # decoded-attachment path (inbox only)
content_sha256: str | None = Field(default=None, index=True)
candidate_email: str | None = Field(default=None)
candidate_name: str | None = Field(default=None)
job_title: str | None = Field(default=None)
current_company: str | None = Field(default=None)
@ -204,6 +294,25 @@ class Candidates(SQLModel, table=True):
result = await session.execute(statement)
return result.scalars().all()
@classmethod
async def get_completed_by_email_job(cls, session: AsyncSession, email, job_id):
"""Newest completed score for this email against this job — keywords when
ats_results.candidate_id is NULL (matched-user identity)."""
normalized = (email or "").strip().lower()
jid = cls._as_uuid(job_id)
if not normalized or jid is None:
return None
result = await session.execute(
select(cls)
.where(
func.lower(cls.candidate_email) == normalized,
cls.job_id == jid,
cls.status == "completed",
)
.order_by(cls.updated_at.desc())
)
return result.scalars().first()
@classmethod
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
existing = None
@ -525,16 +634,20 @@ class Feedback(SQLModel, table=True):
class ApplicationStageTransitions(SQLModel, table=True):
"""Temporal history of inbox_messages.application_status changes.
"""Temporal history of application stage changes.
valid_from / valid_to make time-in-stage a subtraction rather than a window
function. NULL valid_to means the stage is still current.
Inbox moves write inbox_messages.application_status; manual-upload moves
write manual_upload_candidate.status. Exactly one of inbox_id /
manual_upload_candidate_id is set. NULL valid_to means the stage is current.
"""
__tablename__ = "application_stage_transitions"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
inbox_id: int = Field(index=True, foreign_key="inbox.id")
inbox_id: int | None = Field(default=None, index=True, foreign_key="inbox.id")
manual_upload_candidate_id: uuid.UUID | None = Field(
default=None, index=True, foreign_key="manual_upload_candidate.id"
)
from_stage: str | None = Field(default=None)
to_stage: str
valid_from: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@ -569,12 +682,28 @@ class ApplicationStageTransitions(SQLModel, table=True):
return list(result.scalars().all())
@classmethod
async def get_open_transition(cls, session: AsyncSession, inbox_id: int):
async def fetch_by_manual(cls, session: AsyncSession, record_id):
uid = cls._as_uuid(record_id)
if uid is None:
return []
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id), cls.valid_to.is_(None))
.order_by(cls.valid_from.desc())
select(cls).where(cls.manual_upload_candidate_id == uid).order_by(cls.valid_from.desc())
)
return list(result.scalars().all())
@classmethod
async def get_open_transition(cls, session: AsyncSession, inbox_id=None, manual_upload_candidate_id=None):
statement = select(cls).where(cls.valid_to.is_(None)).order_by(cls.valid_from.desc())
if inbox_id is not None:
statement = statement.where(cls.inbox_id == int(inbox_id))
elif manual_upload_candidate_id is not None:
uid = cls._as_uuid(manual_upload_candidate_id)
if uid is None:
return None
statement = statement.where(cls.manual_upload_candidate_id == uid)
else:
return None
result = await session.execute(statement)
return result.scalars().first()
@classmethod
@ -587,8 +716,10 @@ class ApplicationStageTransitions(SQLModel, table=True):
return row
@classmethod
async def close_open(cls, session: AsyncSession, inbox_id: int, *, at: datetime | None = None, commit: bool = False):
row = await cls.get_open_transition(session, inbox_id)
async def close_open(cls, session: AsyncSession, inbox_id=None, *, manual_upload_candidate_id=None, at: datetime | None = None, commit: bool = False):
row = await cls.get_open_transition(
session, inbox_id=inbox_id, manual_upload_candidate_id=manual_upload_candidate_id
)
if not row:
return None
row.valid_to = at or _now()

View File

@ -94,6 +94,49 @@ def build_job_description(job) -> str:
return "\n".join(lines)
def candidate_base_fields(source):
return {
"filename": source["safe_name"],
"file_path": source["file_path"],
"content_sha256": source["sha256"],
"candidate_email": source.get("candidate_email"),
}
def candidate_failed_fields(source, code, message):
return {
**candidate_base_fields(source),
"status": "failed",
"error_code": str(code),
"error_message": message,
"candidate_name": None,
"job_title": None,
"current_company": None,
"years_experience": None,
"match_score": None,
"matched_keywords": [],
"missing_keywords": [],
"summary_critique": None,
}
def candidate_completed_fields(source, result):
return {
**candidate_base_fields(source),
"status": "completed",
"candidate_name": result.candidate_name,
"job_title": result.job_title,
"current_company": result.current_company,
"years_experience": result.years_experience,
"match_score": result.match_score,
"matched_keywords": result.matched_keywords,
"missing_keywords": result.missing_keywords,
"summary_critique": result.summary_critique,
"error_code": None,
"error_message": None,
}
@normalize_unicode
@despace_line
def normalize_spaced_text(text) -> str:

View File

@ -11,11 +11,11 @@ def serialize_candidate(row) -> dict:
return {
"id": str(row.id),
"job_id": str(row.job_id),
"inbox_message_id": str(row.inbox_message_id) if row.inbox_message_id else None,
"source": row.source,
"filename": row.filename,
"file_path": row.file_path,
"content_sha256": row.content_sha256,
"candidate_email": row.candidate_email,
"candidate_name": row.candidate_name,
"job_title": row.job_title,
"current_company": row.current_company,
@ -44,6 +44,7 @@ def serialize_manual_upload_candidate(row) -> Dict[str,Any]:
"full_text":row.full_text,
"current_company":row.current_company,
"current_position":row.current_position,
"apply_via":row.apply_via,
"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,
@ -72,6 +73,7 @@ def serialize_candidate_profile(
payload = {
"inbox_id": link.id,
"user_id": str(link.user_id) if link.user_id else None,
"candidate_id": None,
"name": user.name if user else None,
"email": user.email if user else None,
"is_active": user.is_active if user else None,
@ -138,6 +140,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
return {
"inbox_id": None,
"user_id": str(user.id) if user else (str(row.user_id) if row.user_id else None),
"candidate_id": 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,

View File

@ -17,6 +17,8 @@ from job.candidate.models import Candidates
from job.candidate.plugins import (
FILE_NOT_FOUND,
build_job_description,
candidate_completed_fields,
candidate_failed_fields,
get_scorer,
get_scoring_settings,
normalize_spaced_text,
@ -272,7 +274,6 @@ class CandidateScoring:
"filename":filename or "resume.pdf",
"data":data,
"file_path":None,
"inbox_message_id":None,
"precheck":None,
}
if not (filename or "").lower().endswith(".pdf"):
@ -301,7 +302,8 @@ class CandidateScoring:
"filename":path.name,
"data":None,
"file_path":str(path),
"inbox_message_id":row.id,
"inbox_message_id":row.id, # call-scoped; not persisted on Candidates
"candidate_email":(row.message_from or "").strip().lower() or None,
"precheck":None,
}
suffix=path.suffix.lower()
@ -344,10 +346,24 @@ class CandidateScoring:
jd=build_job_description(job)
if len(jd)>settings.max_jd_chars:
raise HTTPException(status_code=422,detail="The job post is too large to score against")
fields_by_slot=await self._score_sources(sources,jd,settings)
common={
"job_id":job.id,
"source":source_kind,
"created_by":uuid.UUID(str(current_user["id"])),
"model":settings.openai_model,
}
rows=[]
for slot in range(len(sources)):
rows.append(await Candidates.upsert_candidate(self.session,{**fields_by_slot[slot],**common}))
await self._sync_ats_results(source_kind,job,rows,sources)
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
return [serialize_candidate(row) for row in rows]
# Slot-indexed like app/api/routes.py: results merge back by position, never
# by filename — inbox attachments can share a basename.
results_by_slot={}
async def _score_sources(self,sources,jd,settings):
# Slot-indexed: results merge back by position, never by filename —
# inbox attachments can share a basename.
fields_by_slot={}
extracted=[]
for slot,source in enumerate(sources):
source["safe_name"]=sanitize_filename(source["filename"])
@ -355,21 +371,20 @@ class CandidateScoring:
source["sha256"]=hashlib.sha256(data).hexdigest() if data is not None else None
if source["precheck"] is not None:
code,message=source["precheck"]
results_by_slot[slot]=self._failed_fields(source,code,message)
fields_by_slot[slot]=candidate_failed_fields(source,code,message)
continue
try:
# pypdf is CPU-bound: keep it off the event loop. Despace BEFORE
# scoring so keyword verification sees the exact text the model saw;
# ExtractedResume is frozen, hence dataclasses.replace.
resume=await asyncio.to_thread(
extract_resume,data,source["safe_name"],settings.max_resume_chars
)
resume=dataclasses.replace(resume,text=normalize_spaced_text(resume.text))
if not source.get("candidate_email"):
detected,_=extract_candidate_email(resume.text)
source["candidate_email"]=detected
except ATSError as exc:
results_by_slot[slot]=self._failed_fields(source,exc.error_code,exc.public_message)
fields_by_slot[slot]=candidate_failed_fields(source,exc.error_code,exc.public_message)
continue
extracted.append((slot,resume))
scored=await score_batch(
[resume for _,resume in extracted],
job_description=jd,
@ -379,54 +394,27 @@ class CandidateScoring:
for (slot,_),result in zip(extracted,scored,strict=True):
source=sources[slot]
if isinstance(result,CompletedCandidate):
results_by_slot[slot]={
**self._base_fields(source),
"status":"completed",
"candidate_name":result.candidate_name,
"job_title":result.job_title,
"current_company":result.current_company,
"years_experience":result.years_experience,
"match_score":result.match_score,
"matched_keywords":result.matched_keywords,
"missing_keywords":result.missing_keywords,
"summary_critique":result.summary_critique,
"error_code":None,
"error_message":None,
}
fields_by_slot[slot]=candidate_completed_fields(source,result)
else:
results_by_slot[slot]=self._failed_fields(source,result.error_code,result.error_message)
fields_by_slot[slot]=candidate_failed_fields(source,result.error_code,result.error_message)
return fields_by_slot
common={
"job_id":job.id,
"source":source_kind,
"created_by":uuid.UUID(str(current_user["id"])),
"model":settings.openai_model,
}
rows=[]
for slot in range(len(sources)):
fields={**results_by_slot[slot],**common}
rows.append(await Candidates.upsert_candidate(self.session,fields))
# Every completed score lands in ats_results. Inbox scores additionally
# denormalise onto inbox_messages / inbox (one sync per message: a
# multi-attachment mail keeps its best completed score); upload scores
# chain per candidates row instead — they have no inbox application.
async def _sync_ats_results(self,source_kind,job,rows,sources):
if source_kind=="inbox":
best={}
for row in rows:
if row.status=="completed" and row.inbox_message_id:
cur=best.get(row.inbox_message_id)
for source,row in zip(sources,rows):
mid=source.get("inbox_message_id")
if row.status=="completed" and mid:
cur=best.get(mid)
if cur is None or (row.match_score or 0)>(cur.match_score or 0):
best[row.inbox_message_id]=row
best[mid]=row
for message_id,row in best.items():
try:
await self._sync_inbox_ats(message_id,job,row)
except Exception:
# The candidates row is the primary outcome and is already
# committed; a denorm failure must not fail the scoring call.
await self.session.rollback()
logger.exception("inbox ATS denorm failed for message %s",message_id)
else:
return
for row in rows:
if row.status!="completed":
continue
@ -436,121 +424,50 @@ class CandidateScoring:
await self.session.rollback()
logger.exception("upload ATS history failed for candidate %s",row.id)
# Leaderboard order: completed by score desc, failures last, stable.
rows.sort(key=lambda r:(0,-(r.match_score or 0)) if r.status=="completed" else (1,0))
return [serialize_candidate(row) for row in rows]
@staticmethod
def _base_fields(source):
return {
"inbox_message_id":source["inbox_message_id"],
"filename":source["safe_name"],
"file_path":source["file_path"],
"content_sha256":source["sha256"],
}
@classmethod
def _failed_fields(cls,source,code,message):
return {
**cls._base_fields(source),
"status":"failed",
"error_code":str(code),
"error_message":message,
"candidate_name":None,
"job_title":None,
"current_company":None,
"years_experience":None,
"match_score":None,
"matched_keywords":[],
"missing_keywords":[],
"summary_critique":None,
}
async def _sync_inbox_ats(self,message_id,job,row):
"""Land a completed score on the inbox tables (README "What is missing").
"""Land a completed score on inbox_messages / inbox / ats_results.
inbox_messages.ats_score / ats_band are what serialize_application renders
on the Applications tab; ats_results keeps the per-application history that
candidates' in-place upsert cannot. Precedence mirrors _recommendation: a
score against the assigned job always wins the denormalised columns, any
other job's score only lands while no completed assigned-job score exists.
The history row is appended regardless it records the scoring event.
message_id is the scoring call's known inbox_messages PK, not a column
read off the Candidates row.
"""
msg=await Inbox_Messages.get_inbox_message_by_id(self.session,message_id)
if msg is None:
return
band=CandidateView._recommendation(row.match_score) or ""
denorm=True
assigned=msg.assigned_job_post_id
if assigned and str(assigned)!=str(job.id):
outranked=await self.session.execute(
select(Candidates).where(
Candidates.inbox_message_id==msg.id,
Candidates.job_id==assigned,
Candidates.status=="completed",
)
)
denorm=outranked.scalars().first() is None
if denorm:
msg.ats_score=float(row.match_score)
msg.ats_band=band
self.session.add(msg)
# ats_results hangs off the inbox JOIN row (int PK), which only exists once
# the sender is linked to a users account; without it there is no history row.
link=await Inbox.get_inbox_by_message_id(self.session,message_id)
if link is not None:
prev=await AtsResults.get_current_for_inbox(self.session,link.id)
entry=AtsResults(
inbox_id=link.id,
candidate_id=row.id,
job_post_id=job.id,
overall_score=float(row.match_score),
band=band,
model_name=row.model,
is_current=True,
)
self.session.add(entry)
# Flush the INSERT before touching prev/link: with no relationship()
# edge the unit of work emits the UPDATEs first, and both FKs
# (superseded_by_id, inbox.ats_id) reject a pointer to a row that is
# not inserted yet.
await self.session.flush()
if prev is not None:
prev.is_current=False
prev.superseded_by_id=entry.id
self.session.add(prev)
# inbox.ats_id always points at the CURRENT score row.
link.ats_id=entry.id
link.updated_at=datetime.now(timezone.utc)
self.session.add(link)
await self.session.commit()
if assigned and str(assigned)!=str(job.id) and link is not None:
existing=await AtsResults.get_for_inbox_job(self.session,link.id,assigned)
denorm=existing is None
if denorm:
await Inbox_Messages.set_ats_score(self.session,message_id,row.match_score,band)
if link is None:
return
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
await AtsResults.insert_result(self.session,{
"inbox_id":link.id,
**identity,
"job_post_id":job.id,
"overall_score":float(row.match_score),
"band":band,
"model_name":row.model,
"is_current":True,
})
async def _sync_upload_ats(self,job,row):
"""History row for an upload-sourced score — no inbox application exists,
so inbox_id stays NULL and the supersede chain runs per candidates row
(stable across re-scores: upsert keeps the id for the same job+bytes)."""
"""History row for an upload-sourced score — inbox_id stays NULL."""
band=CandidateView._recommendation(row.match_score) or ""
prev=await AtsResults.get_current_for_candidate(self.session,row.id)
entry=AtsResults(
inbox_id=None,
candidate_id=row.id,
job_post_id=job.id,
overall_score=float(row.match_score),
band=band,
model_name=row.model,
is_current=True,
)
self.session.add(entry)
# Same flush-before-pointing rule as the inbox path: the FK on
# superseded_by_id must see the new row inserted first.
await self.session.flush()
if prev is not None:
prev.is_current=False
prev.superseded_by_id=entry.id
self.session.add(prev)
await self.session.commit()
identity=await AtsResults.resolve_identity(self.session,row.candidate_email,row.id)
await AtsResults.insert_result(self.session,{
"inbox_id":None,
**identity,
"job_post_id":job.id,
"overall_score":float(row.match_score),
"band":band,
"model_name":row.model,
"is_current":True,
})
class CandidateView:
@ -564,23 +481,14 @@ class CandidateView:
# Same bands the frontend uses (Candidates.jsx / seed.js).
return "Strong Match" if score>=82 else "Potential Match" if score>=65 else "Weak Match"
async def _scores_by_message(self,message_ids):
"""Completed ATS scores (candidates table) per inbox message id, newest first.
One batched query the profile list would otherwise pay a query per row.
"""
mids=[m for m in message_ids if m]
if not mids:
return {}
result=await self.session.execute(
select(Candidates)
.where(Candidates.inbox_message_id.in_(mids),Candidates.status=="completed")
.order_by(Candidates.updated_at.desc())
)
scores={}
for row in result.scalars().all():
scores.setdefault(row.inbox_message_id,[]).append(row)
return scores
@staticmethod
def _score_from_message(record):
"""Inbox denorm on the already-loaded messages row — not a Candidates join."""
msg=getattr(record,"messages",None)
if msg is None or msg.ats_score is None:
return None,None
band=(msg.ats_band or "").strip() or None
return msg.ats_score,band or CandidateView._recommendation(msg.ats_score)
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:
@ -632,7 +540,19 @@ class CandidateView:
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)
payload=serialize_manual_candidate_profile(manual,user,job_post)
from inbox.plugins import get_ats_score_for_manual_user
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
if score:
payload["ai_score"]=score["overall_score"]
payload["recommendation"]=self._recommendation(score["overall_score"])
payload["scored_at"]=score["computed_at"]
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
return await self.attach_job_posts(rows)
except HTTPException:
raise
@ -729,7 +649,6 @@ class CandidateView:
"""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
payloads=[]
wanted=[]
@ -737,10 +656,10 @@ class CandidateView:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
payload["assigned_job_post"]=None
scored=scores.get(getattr(record,"message_id",None)) or []
if scored:
payload["ai_score"]=scored[0].match_score
payload["recommendation"]=self._recommendation(scored[0].match_score)
score,band=self._score_from_message(record)
if score is not None:
payload["ai_score"]=score
payload["recommendation"]=band
payloads.append(payload)
if payload.get("assigned_job_post_id"):
wanted.append(payload["assigned_job_post_id"])
@ -821,26 +740,46 @@ class CandidateView:
)
notes=[serialize_note(r) for r in result.scalars().all()]
# ATS score: join the scoring engine's `candidates` rows onto the profile
# by inbox message. The serializer stubs ai_score/recommendation to None;
# this is where they get real values. Prefer the score against the
# assigned job post, else the most recent completed score.
scores=await self._scores_by_message([getattr(r,"message_id",None) for r in records])
scored_rows=[row for rows in scores.values() for row in rows]
if scored_rows:
assigned_uid=Candidates._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
# ATS score from inbox denorm / ats_results via Inbox.ats_id — never from
# a Candidates join on message id. Keywords live on the scored Candidates
# row: candidate_id when set, else email+job for the matched-user path.
ats_ids=[r.ats_id for r in records if getattr(r,"ats_id",None)]
ats_rows=[]
if ats_ids:
result=await self.session.execute(select(AtsResults).where(AtsResults.id.in_(ats_ids)))
ats_rows=list(result.scalars().all())
assigned_uid=AtsResults._as_uuid(base.get("assigned_job_post_id")) if base.get("assigned_job_post_id") else None
chosen=None
if assigned_uid is not None:
chosen=next((r for r in scored_rows if r.job_id==assigned_uid),None)
if chosen is None:
chosen=max(scored_rows,key=lambda r:r.updated_at)
base["ai_score"]=chosen.match_score
base["recommendation"]=self._recommendation(chosen.match_score)
base["matched_keywords"]=list(chosen.matched_keywords or [])
base["missing_keywords"]=list(chosen.missing_keywords or [])
base["summary_critique"]=chosen.summary_critique
base["scored_job_post_id"]=str(chosen.job_id)
base["scored_at"]=chosen.updated_at.isoformat() if chosen.updated_at else None
chosen=next((a for a in ats_rows if a.job_post_id==assigned_uid),None)
if chosen is None and ats_rows:
chosen=max(ats_rows,key=lambda a:a.computed_at)
if chosen is not None:
base["ai_score"]=chosen.overall_score
base["recommendation"]=chosen.band or self._recommendation(chosen.overall_score)
base["scored_job_post_id"]=str(chosen.job_post_id) if chosen.job_post_id else None
base["scored_at"]=chosen.computed_at.isoformat() if chosen.computed_at else None
base["candidate_id"]=str(chosen.candidate_id) if chosen.candidate_id else None
if chosen.user_id and not base.get("user_id"):
base["user_id"]=str(chosen.user_id)
scored=None
if chosen.candidate_id:
scored=await Candidates.get_candidate_by_id(self.session,str(chosen.candidate_id))
elif chosen.user_id:
owner=await Users.get_user_by_id(self.session,str(chosen.user_id))
if owner is not None:
scored=await Candidates.get_completed_by_email_job(self.session,owner.email,chosen.job_post_id)
if scored is not None:
base["matched_keywords"]=list(scored.matched_keywords or [])
base["missing_keywords"]=list(scored.missing_keywords or [])
base["summary_critique"]=scored.summary_critique
else:
for record in records:
score,band=self._score_from_message(record)
if score is not None:
base["ai_score"]=score
base["recommendation"]=band
break
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
base["favorite"]=favorite

View File

@ -1,7 +1,29 @@
from inbox.enums import Candidate_application_Status
def serialize_pipeline_counts(inbox_counts,manual_counts) -> dict:
by_status={stage.value:0 for stage in Candidate_application_Status}
by_status["UNKNOWN"]=0
inbox_n=0
manual_n=0
for key,n in (inbox_counts or {}).items():
n=int(n or 0)
inbox_n+=n
bucket=key if key in by_status else "UNKNOWN"
by_status[bucket]+=n
for key,n in (manual_counts or {}).items():
n=int(n or 0)
manual_n+=n
bucket=key if key in by_status else "UNKNOWN"
by_status[bucket]+=n
return {"by_status":by_status,"inbox":inbox_n,"manual_upload":manual_n}
def serialize_stage_transition(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"manual_upload_candidate_id": str(row.manual_upload_candidate_id) if row.manual_upload_candidate_id else None,
"from_stage": row.from_stage,
"to_stage": row.to_stage,
"valid_from": row.valid_from.isoformat() if row.valid_from else None,

View File

@ -3,55 +3,92 @@ from sqlalchemy.ext.asyncio import AsyncSession
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox
from job.candidate.models import ApplicationStageTransitions
from job.pipeline.serializers import serialize_stage_transition
from job.candidate.models import ApplicationStageTransitions, Manual_UPLOAD_CANDIDATE, _now
from job.pipeline.serializers import serialize_pipeline_counts, serialize_stage_transition
from inbox.plugins import get_ats_score_for_manual_user, get_ats_score_for_user
class Pipeline:
def __init__(self,session:AsyncSession):
self.session=session
async def get_transitions(self,inbox_id=None,transition_id=None):
async def get_all(self,job_post_id=None,limit=None,offset=0):
# limit/offset are per-source, not a merged page: two tables, no common
# order key. limit=200 returns up to 200 inbox AND up to 200 manual rows.
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)
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),
)
return {
"data":{"inbox":inbox_data,"manual_upload":manual_upload_data},
"counts":counts,
"total":counts["inbox"]+counts["manual_upload"],
}
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def get_pipeline_candidates(self,user_id=None,job_post_id=None):
try:
manual_data=await get_ats_score_for_manual_user(self.session,user_id,job_post_id)
inbox_data=await get_ats_score_for_user(self.session,user_id,job_post_id)
data={"manual":manual_data,"inbox":inbox_data}
return data
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def get_transitions(self,inbox_id=None,transition_id=None,manual_upload_id=None):
if transition_id:
row=await ApplicationStageTransitions.get_by_id(self.session,transition_id)
if not row:
raise HTTPException(status_code=404,detail="Transition not found")
return serialize_stage_transition(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="transition_id or inbox_id is required")
if inbox_id is not None:
rows=await ApplicationStageTransitions.fetch_by_inbox(self.session,int(inbox_id))
return [serialize_stage_transition(r) for r in rows]
if manual_upload_id is not None:
rows=await ApplicationStageTransitions.fetch_by_manual(self.session,manual_upload_id)
return [serialize_stage_transition(r) for r in rows]
raise HTTPException(status_code=400,detail="transition_id, inbox_id or manual_upload_id is required")
async def change_stage(self,inbox_id,to_stage,current_user,change_reason=None):
async def change_stage(self,to_stage,current_user,inbox_id=None,manual_upload_id=None,change_reason=None):
if (inbox_id is None)==(manual_upload_id is None):
raise HTTPException(status_code=400,detail="inbox_id or manual_upload_id is required")
try:
stage=Candidate_application_Status(to_stage)
except ValueError:
raise HTTPException(status_code=422,detail="Invalid to_stage")
changed_by=None
if isinstance(current_user,dict) and current_user.get("id"):
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
if inbox_id is not None:
return await self._change_inbox_stage(inbox_id,stage,changed_by,change_reason)
return await self._change_manual_stage(manual_upload_id,stage,changed_by,change_reason)
async def _change_inbox_stage(self,inbox_id,stage,changed_by,change_reason):
inbox=await Inbox.get_inbox_with_message(self.session,inbox_id)
if not inbox:
raise HTTPException(status_code=404,detail="Inbox not found")
message=inbox.messages
if not message:
raise HTTPException(status_code=404,detail="Inbox message not found")
try:
stage=Candidate_application_Status(to_stage)
except ValueError:
raise HTTPException(status_code=422,detail="Invalid to_stage")
current=message.application_status
from_stage=current.value if isinstance(current,Candidate_application_Status) else str(current)
if from_stage==stage.value:
raise HTTPException(status_code=400,detail="already at stage")
changed_by=None
if isinstance(current_user,dict) and current_user.get("id"):
changed_by=ApplicationStageTransitions._as_uuid(current_user.get("id"))
await ApplicationStageTransitions.close_open(self.session,inbox.id,commit=False)
transition_data={
transition=await ApplicationStageTransitions.insert_transition(
self.session,
{
"inbox_id":inbox.id,
"manual_upload_candidate_id":None,
"from_stage":from_stage,
"to_stage":stage.value,
"changed_by":changed_by,
"actor_kind":"user",
"change_reason":change_reason,
}
transition=await ApplicationStageTransitions.insert_transition(
self.session,
transition_data,
},
commit=False,
)
message.application_status=stage
@ -59,6 +96,41 @@ class Pipeline:
await self.session.commit()
return {
"inbox_id":inbox.id,
"manual_upload_id":None,
"application_status":stage.value,
"transition":serialize_stage_transition(transition),
}
async def _change_manual_stage(self,manual_upload_id,stage,changed_by,change_reason):
row=await Manual_UPLOAD_CANDIDATE.get_by_id(self.session,manual_upload_id)
if not row:
raise HTTPException(status_code=404,detail="Manual upload candidate not found")
from_stage=(row.status or "").strip() or None
if from_stage==stage.value:
raise HTTPException(status_code=400,detail="already at stage")
await ApplicationStageTransitions.close_open(
self.session,manual_upload_candidate_id=row.id,commit=False,
)
transition=await ApplicationStageTransitions.insert_transition(
self.session,
{
"inbox_id":None,
"manual_upload_candidate_id":row.id,
"from_stage":from_stage,
"to_stage":stage.value,
"changed_by":changed_by,
"actor_kind":"user",
"change_reason":change_reason,
},
commit=False,
)
row.status=stage.value
row.updated_at=_now()
self.session.add(row)
await self.session.commit()
return {
"inbox_id":None,
"manual_upload_id":str(row.id),
"application_status":stage.value,
"transition":serialize_stage_transition(transition),
}

View File

@ -8,10 +8,24 @@
-- INSERT skips any application that already has an ats_results row.
-- Applied automatically at startup by alembic_setup.run_manual_sql() once the
-- schema is at head; recorded in manual_migrations. Safe to re-run by hand.
--
-- The Candidates.inbox_message_id join is historical: that column is dropped
-- after revision b7d4e8f1a203. Those three statements run only when the column
-- is still present (a DB that has not yet reached that revision). Fresh installs
-- skip them — there is no pre-denorm history to copy.
-- Winning score per message, mirroring CandidateView._recommendation precedence:
-- the completed score against the ASSIGNED job wins, else the newest completed.
WITH ranked AS (
DO $backfill$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'candidates'
AND column_name = 'inbox_message_id'
) THEN
-- Winning score per message, mirroring CandidateView._recommendation precedence:
-- the completed score against the ASSIGNED job wins, else the newest completed.
EXECUTE $sql$
WITH ranked AS (
SELECT
c.inbox_message_id,
c.job_id,
@ -27,23 +41,25 @@ WITH ranked AS (
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
WHERE c.status = 'completed'
AND c.inbox_message_id IS NOT NULL
)
UPDATE app.inbox_messages m
SET ats_score = r.match_score,
)
UPDATE app.inbox_messages m
SET ats_score = r.match_score,
ats_band = CASE
WHEN r.match_score >= 82 THEN 'Strong Match'
WHEN r.match_score >= 65 THEN 'Potential Match'
ELSE 'Weak Match'
END
FROM ranked r
WHERE r.rn = 1
FROM ranked r
WHERE r.rn = 1
AND m.id = r.inbox_message_id
AND m.ats_score IS NULL;
AND m.ats_score IS NULL
$sql$;
-- One current ats_results row per already-scored application. computed_at takes
-- the candidates row's timestamp so the history reflects when the score happened.
-- inbox can hold several join rows per message; DISTINCT ON keeps the newest.
WITH ranked AS (
-- One current ats_results row per already-scored application. computed_at takes
-- the candidates row's timestamp so the history reflects when the score happened.
-- inbox can hold several join rows per message; DISTINCT ON keeps the newest.
EXECUTE $sql$
WITH ranked AS (
SELECT
c.inbox_message_id,
c.job_id,
@ -59,16 +75,16 @@ WITH ranked AS (
JOIN app.inbox_messages m ON m.id = c.inbox_message_id
WHERE c.status = 'completed'
AND c.inbox_message_id IS NOT NULL
),
links AS (
),
links AS (
SELECT DISTINCT ON (message_id) message_id, id AS inbox_id
FROM app.inbox
ORDER BY message_id, created_at DESC
)
INSERT INTO app.ats_results
)
INSERT INTO app.ats_results
(id, inbox_id, job_post_id, overall_score, band, is_current,
superseded_by_id, model_name, computed_at, created_at)
SELECT
SELECT
gen_random_uuid(),
l.inbox_id,
r.job_id,
@ -83,12 +99,28 @@ SELECT
r.model,
r.updated_at,
NOW()
FROM ranked r
JOIN links l ON l.message_id = r.inbox_message_id
WHERE r.rn = 1
FROM ranked r
JOIN links l ON l.message_id = r.inbox_message_id
WHERE r.rn = 1
AND NOT EXISTS (
SELECT 1 FROM app.ats_results a WHERE a.inbox_id = l.inbox_id
);
)
$sql$;
-- Older inbox history rows predate candidate_id; link the current ones back to
-- the candidates row that produced them.
EXECUTE $sql$
UPDATE app.ats_results a
SET candidate_id = c.id
FROM app.inbox i
JOIN app.candidates c ON c.inbox_message_id = i.message_id AND c.status = 'completed'
WHERE a.inbox_id = i.id
AND a.candidate_id IS NULL
AND c.job_id = a.job_post_id
$sql$;
END IF;
END
$backfill$;
-- inbox.ats_id -> the CURRENT ats_results row for that application, so the
-- score is one direct id join away. Newest current row wins if several exist.
@ -131,13 +163,3 @@ WHERE c.status = 'completed'
AND NOT EXISTS (
SELECT 1 FROM app.ats_results a WHERE a.candidate_id = c.id
);
-- Older inbox history rows predate candidate_id; link the current ones back to
-- the candidates row that produced them.
UPDATE app.ats_results a
SET candidate_id = c.id
FROM app.inbox i
JOIN app.candidates c ON c.inbox_message_id = i.message_id AND c.status = 'completed'
WHERE a.inbox_id = i.id
AND a.candidate_id IS NULL
AND c.job_id = a.job_post_id;

View File

@ -57,7 +57,6 @@ class Users(SQLModel, table=True):
)
password: str
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=False)

View File

@ -23,7 +23,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-D1YVNnju.js"></script>
<script type="module" crossorigin src="/assets/index-D0GY3L2I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CSn67wit.css">
</head>
<body>

View File

@ -83,7 +83,6 @@ export function toCandidateView(row) {
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
inboxMessageId: row.inbox_message_id ?? null,
}
}
@ -115,7 +114,7 @@ export function listCandidateUsers({ roleId = 8, top = 500, skip = 0 } = {}) {
*
* A user account carries identity only. Everything the ATS produces
* (score, matched skills, critique, the job it was scored against) lives in the
* `candidates` table keyed by inbox_message_id, with no user_id to join on, so
* `candidates` table keyed by job_id + content hash, with no user_id to join on, so
* those fields are null here by construction rather than by omission.
*/
export function toCandidateUserView(row) {
@ -141,7 +140,6 @@ export function toCandidateUserView(row) {
scoringStatus: null,
errorCode: null,
errorMessage: null,
inboxMessageId: null,
}
}

View File

@ -1,17 +1,13 @@
/* ============================================================
pipeline.js the kanban board's endpoints (backend/job/app.py).
The board is stitched from two modules, because there is no pipeline-specific
READ endpoint:
The board is one read: GET /pipeline/candidates/fetch returns inbox +
manual_upload cards plus per-status counts. Dropping a card fires
PATCH /candidate/stage with `inbox_id` or `manual_upload_id`.
- rows come from GET /candidate/fetch (api/candidates.js `list`), the
inbox -> users -> roles join, which is the only list payload carrying BOTH
`application_status` (the stage) and `inbox_id` (what the write below needs);
- the write is PATCH /candidate/stage, here.
Stage lives on inbox_messages.application_status and the transition history in
application_stage_transitions; the server closes the open interval and opens a
new one in the same commit, so the board never has to touch history itself.
Inbox stage lives on inbox_messages.application_status; manual stage lives
on manual_upload_candidate.status (same Candidate_application_Status values).
History is application_stage_transitions in both cases.
============================================================ */
import { request } from '../lib/apiClient'
@ -60,63 +56,107 @@ export const STATUS_FROM_STAGE = {
/**
* Move one application to another stage. Requires pipeline.edit.
*
* `inboxId` is the INTEGER inbox.id the row the candidate profile returns as
* `inbox_id`, not the inbox_messages uuid the Inbox screen calls `id`; the route
* runs int() on it and 404s on anything else.
*
* The server rejects a no-op move with 400 ("already at stage"), so callers must
* not fire on a drop into the card's current column.
* Inbox cards send `inboxId` (INTEGER inbox.id). Manual-upload cards send
* `manualUploadId` (manual_upload_candidate.id UUID). The server 400s if both
* or neither are present, and 400s a no-op move ("already at stage").
*/
export function changeStage({ inboxId, toStage, changeReason }) {
export function changeStage({ inboxId, manualUploadId, toStage, changeReason }) {
return request('/candidate/stage', {
method: 'PATCH',
body: { inbox_id: inboxId, to_stage: toStage, change_reason: changeReason ?? null },
body: {
...(inboxId != null ? { inbox_id: inboxId } : {}),
...(manualUploadId != null ? { manual_upload_id: manualUploadId } : {}),
to_stage: toStage,
change_reason: changeReason ?? null,
},
})
}
/**
* Inbox + manual-upload applications for the board GET /pipeline/candidates/fetch
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
* `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter.
*/
export function listApplications({ jobId, limit, offset } = {}) {
return request('/pipeline/candidates/fetch', {
params: { job_post_id: jobId, limit, offset },
})
}
/**
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
* land in Applied, same as STAGE_FROM_STATUS's card fallback.
*/
export function toStageCounts(byStatus) {
const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0]))
for (const [status, n] of Object.entries(byStatus || {})) {
const stage = STAGE_FROM_STATUS[status] ?? 'Applied'
counts[stage] = (counts[stage] ?? 0) + (n || 0)
}
return counts
}
/**
* Stage history for one application GET /pipeline/transitions/fetch
* (pipeline.view). Rows are valid-time intervals: `valid_to` null is the stage
* the candidate is in now. The board itself does not render history; this is the
* feed behind a stage timeline on the profile.
*
* One of inboxId / transitionId is required the route 400s with neither.
* One of inboxId / manualUploadId / transitionId is required the route 400s
* with none of them.
*/
export function listTransitions({ inboxId, transitionId } = {}) {
export function listTransitions({ inboxId, manualUploadId, transitionId } = {}) {
return request('/pipeline/transitions/fetch', {
params: { inbox_id: inboxId, transition_id: transitionId },
params: { inbox_id: inboxId, manual_upload_id: manualUploadId, transition_id: transitionId },
})
}
/**
* Candidate-profile row -> one kanban card.
*
* `id` is the inbox id, not the user id: the board is one card per APPLICATION
* and `inbox` holds one row per (user, message), so a candidate who mailed us
* three times legitimately occupies three cards with three independent stages.
* `userId` rides along for the deep link into the profile.
*
* Skills are absent by construction the list payload carries ai_score but not
* matched_keywords (job/candidate/views.py::attach_job_posts sets only the
* score), so the card drops its tag row rather than rendering three blanks.
*/
export function toBoardCard(row) {
const jobTitle = row.job_title ?? row.assigned_job_post?.title ?? null
function sourceFields(row, kind) {
if (kind === 'manual') {
return {
id: `manual:${row.id}`,
inboxId: null,
manualUploadId: row.id,
jobId: row.job_post_id ?? null,
jobTitle: row.title ?? null,
currentTitle: row.current_position || null,
currentCompany: row.current_company || null,
}
}
return {
id: row.inbox_id,
inboxId: row.inbox_id,
manualUploadId: null,
jobId: row.assigned_job_post_id ?? null,
jobTitle: row.title ?? null,
currentTitle: row.current_title || null,
currentCompany: row.current_employment || null,
}
}
/**
* Pipeline inbox or manual-upload row -> one kanban card.
*
* Inbox `id` is the inbox id, not the user id: the board is one card per
* APPLICATION. `userId` rides along for the deep link into the profile.
*/
export function toBoardCard(row, kind = 'inbox') {
const src = sourceFields(row, kind)
return {
...src,
userId: row.user_id ?? null,
name: row.name || row.email || 'Unknown',
email: row.email ?? null,
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
status: row.application_status ?? null,
jobId: row.assigned_job_post_id ?? null,
jobTitle,
currentTitle: row.current_title || null,
currentCompany: row.current_employment || null,
experience: row.experience || null,
aiScore: row.ai_score ?? null,
recommendation: row.recommendation ?? null,
aiScore: row.ats_result?.overall_score ?? null,
recommendation: row.ats_result?.band ?? null,
applied: row.created_at ? new Date(row.created_at) : null,
}
}
/** GET /pipeline/candidates/fetch `manual_upload` row -> one kanban card. */
export function toManualBoardCard(row) {
return toBoardCard(row, 'manual')
}

View File

@ -1,31 +1,28 @@
/* ============================================================
Pipeline the kanban board, on live backend data.
Cards come from GET /candidate/fetch (the inbox -> users -> roles join), the
only list payload that carries the stage (`application_status`) together with
the `inbox_id` that PATCH /candidate/stage writes against. Dropping a card
fires that PATCH; the server closes the open application_stage_transitions
interval and opens a new one in the same commit.
Cards and column counts come from GET /pipeline/candidates/fetch. Dropping a
card fires PATCH /candidate/stage with `inbox_id` or `manual_upload_id`. The
server closes the open application_stage_transitions interval and opens a
new one in the same commit.
The job filter reads live posts from GET /job/fetch and matches on
`assigned_job_post_id`, so an application nobody has assigned to a post shows
under All Jobs only.
The job filter is server-side (`job_post_id`). Applications with no assigned
post are omitted by the list query, so they do not appear under All Jobs.
The card's skill tags are gone: the list payload has ai_score but no
The card's skill tags are gone: the list payload has ats_result but no
matched_keywords, and the Candidates screen set the precedent that a column
with no source is dropped rather than rendered as blanks.
============================================================ */
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Avatar, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
@ -40,18 +37,21 @@ export const KANBAN_STAGES = [
{ name: 'Rejected', color: 'var(--stage-7)' },
]
/* /candidate/fetch pages with limit/offset and has no job filter, so the board
pulls one page and filters client-side. Rows past this are not on the board
the header says so rather than silently showing a partial pipeline. */
const BOARD_LIMIT = 200
const BOARD_KEY = qk.pipeline.board({ limit: BOARD_LIMIT })
const JOB_LIMIT = 100
async function fetchBoard() {
const res = await candidatesApi.list({ limit: BOARD_LIMIT })
const rows = candidatesApi.toRows(res)
return { cards: rows.map(pipelineApi.toBoardCard), total: res?.total ?? rows.length }
async function fetchBoard(jobId) {
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
return {
cards: [
...inbox.map((row) => pipelineApi.toBoardCard(row)),
...manuals.map((row) => pipelineApi.toManualBoardCard(row)),
],
total: res?.total ?? 0,
stageCounts: pipelineApi.toStageCounts(res?.counts?.by_status),
}
}
async function fetchJobs() {
@ -74,53 +74,67 @@ export default function Pipeline() {
const navigate = useNavigate()
const qc = useQueryClient()
const board = useQuery({ queryKey: BOARD_KEY, queryFn: fetchBoard })
const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs })
const [jobId, setJobId] = useState('')
const [draggingId, setDraggingId] = useState(null)
const [overStage, setOverStage] = useState(null)
const boardKey = useMemo(
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }),
[jobId],
)
const board = useQuery({
queryKey: boardKey,
queryFn: () => fetchBoard(jobId),
placeholderData: keepPreviousData,
})
const { data: jobs = [] } = useQuery({ queryKey: qk.jobPosts.list({ top: JOB_LIMIT }), queryFn: fetchJobs })
/* The route is behind pipeline.view, but the WRITE needs pipeline.edit a
viewer gets a read-only board instead of drags that 403 on drop. */
const canEdit = can('pipeline.edit')
const candidates = board.data?.cards ?? []
const total = board.data?.total ?? 0
const list = useMemo(
() => (jobId ? candidates.filter((c) => c.jobId === jobId) : candidates),
[candidates, jobId],
)
const stageCounts = board.data?.stageCounts ?? {}
const byStage = useMemo(() => {
const map = Object.fromEntries(KANBAN_STAGES.map((s) => [s.name, []]))
for (const c of list) if (map[c.stage]) map[c.stage].push(c)
for (const c of candidates) if (map[c.stage]) map[c.stage].push(c)
return map
}, [list])
}, [candidates])
/* Optimistic: a drag that only repaints after the round trip reads as a failed
drop. The card snaps back on error and the server's own value wins on the
refetch in onSettled. */
refetch in onSettled. Counts move with the card so badges do not lag. */
const move = useMutation({
mutationFn: ({ card, stage }) =>
pipelineApi.changeStage({
inboxId: card.inboxId,
manualUploadId: card.manualUploadId,
toStage: pipelineApi.STATUS_FROM_STAGE[stage],
}),
onMutate: async ({ card, stage }) => {
await qc.cancelQueries({ queryKey: BOARD_KEY })
const previous = qc.getQueryData(BOARD_KEY)
qc.setQueryData(BOARD_KEY, (old) =>
old && {
await qc.cancelQueries({ queryKey: boardKey })
const previous = qc.getQueryData(boardKey)
qc.setQueryData(boardKey, (old) => {
if (!old) return old
const from = card.stage
const nextCounts = { ...old.stageCounts }
if (from && from !== stage) {
nextCounts[from] = Math.max(0, (nextCounts[from] ?? 0) - 1)
nextCounts[stage] = (nextCounts[stage] ?? 0) + 1
}
return {
...old,
cards: old.cards.map((c) => (c.inboxId === card.inboxId ? { ...c, stage } : c)),
},
)
cards: old.cards.map((c) => (c.id === card.id ? { ...c, stage } : c)),
stageCounts: nextCounts,
}
})
return { previous }
},
onError: (err, _vars, ctx) => {
if (ctx?.previous) qc.setQueryData(BOARD_KEY, ctx.previous)
if (ctx?.previous) qc.setQueryData(boardKey, ctx.previous)
toast(friendlyAuthError(err, 'Could not move the candidate.'), 'error')
},
onSuccess: (_data, { card, stage }) => toast(`${card.name} moved to ${stage}`, 'success'),
@ -140,7 +154,7 @@ export default function Pipeline() {
const cand = candidates.find((c) => c.id === id)
// A no-op move is a 400 server-side ("already at stage"), so it never leaves.
if (!cand || cand.stage === stage) return
if (cand.inboxId == null) {
if (cand.inboxId == null && cand.manualUploadId == null) {
toast(`${cand.name} has no application to move`, 'warning')
return
}
@ -190,7 +204,7 @@ export default function Pipeline() {
<div className="kanban-col-head">
<span className="k-dot" style={{ background: st.color }} />
<h4>{st.name}</h4>
<span className="k-count">{cards.length}</span>
<span className="k-count">{stageCounts[st.name] ?? 0}</span>
</div>
<div
className={`kanban-cards${overStage === st.name ? ' drag-over' : ''}`}

View File

@ -64,7 +64,7 @@ export default function ScoredCandidateProfile({ candidate: c, jobTitle, onClose
const filename =
live?.documents?.[0]?.name || c.filename || null
const matchSummary = live?.match_summary ?? null
const messageId = live?.message_id ?? c.inboxMessageId ?? null
const messageId = live?.message_id ?? null
const aiScore = live?.ai_score ?? c.aiScore ?? null
const matchedSkills = live?.matched_keywords ?? c.matchedSkills ?? []
const missingSkills = live?.missing_keywords ?? c.missingSkills ?? []