Merge origin/main (talent pool API + user-candidate linking) into Talha
Both sides had rewritten the candidate surface, so the merge keeps the two features side by side instead of picking one: - GET /candidate/fetch stays main's paginated inbox-profile listing (CandidateView); the scoring leaderboard moved to GET /candidate/scored/fetch and the frontend listCandidates() now points there. - backend candidate views/serializers keep both CandidateScoring and CandidateView, serialize_candidate and serialize_candidate_profile. - Candidates.jsx stays the scored table (Talha); TalentPool.jsx takes main's profile card grid; AtsMatch is exported from Candidates for TalentPool's modal and tolerates a missing jobTitle. - CandidateProfile hides the Scored/Failed badge for rows that were never scored (talent-pool profiles). - queryKeys.js: dropped a duplicated candidates block the auto-merge produced. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>Dashboard_Wiring
commit
927cb37672
|
|
@ -1,6 +1,7 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
|
@ -9,4 +10,4 @@ COPY . .
|
|||
|
||||
# Runs the Taskiq worker against taskiq_management.broker_setup.
|
||||
# docker-compose overrides this command if needed.
|
||||
CMD ["taskiq", "worker", "taskiq_management.broker_setup:broker", "inbox.tasks", "taskiq_management.tasks"]
|
||||
CMD ["taskiq", "worker", "taskiq_management.broker_setup:broker", "inbox.tasks", "inbox.sync_tasks", "taskiq_management.tasks"]
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ async def fetch_email(
|
|||
await service.enqueue_matching(list(service.pending_match_ids),force=False)
|
||||
|
||||
account_setup=[]
|
||||
if test_on:
|
||||
return JSONResponse(content={"data":items_lst,"status_code":200})
|
||||
if service.pending_confirmation_emails:
|
||||
account_setup=await service.send_account_setup(list(service.pending_confirmation_emails))
|
||||
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -12,6 +12,7 @@ from sqlalchemy import Column, DateTime, func, or_, update
|
|||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
from sqlmodel import Field, Relationship, SQLModel, select, true
|
||||
|
||||
from users.models import Users
|
||||
|
|
@ -42,9 +43,59 @@ class Inbox(SQLModel, table=True):
|
|||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
# is_active: bool = Field(default=True)
|
||||
# is_deleted: bool = Field(default=False)
|
||||
# user: Users | None = Relationship(back_populates="inbox")
|
||||
|
||||
user: Optional[Users] = Relationship(
|
||||
back_populates="inbox",
|
||||
sa_relationship_kwargs={"lazy": "joined"},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _candidate_search_filter(cls, search: str):
|
||||
pattern = f"%{search}%"
|
||||
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
|
||||
|
||||
@classmethod
|
||||
async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None):
|
||||
try:
|
||||
qry = (
|
||||
select(cls)
|
||||
.options(selectinload(cls.messages))
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.join(Roles, Users.role_id == Roles.id)
|
||||
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
||||
)
|
||||
if user_id:
|
||||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
qry = qry.limit(limit).offset(offset)
|
||||
result = await session.execute(qry)
|
||||
rows = result.scalars().all()
|
||||
if user_id and len(rows) == 1:
|
||||
return rows[0]
|
||||
return rows
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None):
|
||||
"""Result-set size for the same predicate get_candidate_profile pages over."""
|
||||
try:
|
||||
qry = (
|
||||
select(func.count())
|
||||
.select_from(cls)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.join(Roles, Users.role_id == Roles.id)
|
||||
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
||||
)
|
||||
if user_id:
|
||||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
result = await session.execute(qry)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
class Inbox_Alerts(SQLModel, table=True):
|
||||
|
|
@ -101,14 +152,6 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return body
|
||||
return email_data.get("bodyPreview") or ""
|
||||
|
||||
# @classmethod
|
||||
# async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None):
|
||||
# try:
|
||||
# qryy=select(cls,Users).join(cls,cls.)
|
||||
# if user_id
|
||||
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@classmethod
|
||||
async def get_all_applications(cls,session:AsyncSession,message_id:uuid.UUID|int|None=None):
|
||||
|
|
@ -219,11 +262,12 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
if not cls._is_linkable_sender(address):
|
||||
return None
|
||||
try:
|
||||
user=(await session.execute(
|
||||
select(Users).where(func.lower(Users.email)==address)
|
||||
)).scalars().first()
|
||||
# id-only: avoid Users.job_posts selectin / role lazy loads under asyncio
|
||||
user_id=(await session.execute(
|
||||
select(Users.id).where(func.lower(Users.email)==address)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
if user_id is None:
|
||||
role=await Roles.get_role_by_name(session,EnumRoles.CANDIDATE.value)
|
||||
user=Users(
|
||||
name=cls._sender_display_name(email_data,address),
|
||||
|
|
@ -232,15 +276,18 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
password=hash_password(DEFAULT_CANDIDATE_PASSWORD),
|
||||
)
|
||||
session.add(user)
|
||||
# autoflush=False: flush so users.id exists before inbox FK insert
|
||||
# (Relationship helps ordering, but flush keeps this path explicit).
|
||||
await session.flush()
|
||||
session.add(Inbox(user_id=user.id,message_id=email.id))
|
||||
await session.commit()
|
||||
return address
|
||||
|
||||
link=(await session.execute(
|
||||
select(Inbox).where(Inbox.message_id==email.id,Inbox.user_id==user.id)
|
||||
)).scalars().first()
|
||||
if not link:
|
||||
session.add(Inbox(user_id=user.id,message_id=email.id))
|
||||
select(Inbox.id).where(Inbox.message_id==email.id,Inbox.user_id==user_id)
|
||||
)).scalar_one_or_none()
|
||||
if link is None:
|
||||
session.add(Inbox(user_id=user_id,message_id=email.id))
|
||||
await session.commit()
|
||||
return None
|
||||
except IntegrityError:
|
||||
|
|
@ -275,17 +322,21 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
session.add(existing)
|
||||
await session.commit()
|
||||
await session.refresh(existing)
|
||||
|
||||
|
||||
if fields.get("attachment"):
|
||||
link_user=await cls._link_sender(session, email_data, existing)
|
||||
# _link_sender may rollback (IntegrityError); that expires this row
|
||||
await session.refresh(existing)
|
||||
return existing, link_user
|
||||
|
||||
email = cls(**fields)
|
||||
session.add(email)
|
||||
await session.commit()
|
||||
await session.refresh(email)
|
||||
|
||||
if fields.get("attachment"):
|
||||
link_user=await cls._link_sender(session, email_data, email)
|
||||
await session.refresh(email)
|
||||
return email, link_user
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -36,14 +36,17 @@ async def request_email_confirmation(email):
|
|||
return response.status_code
|
||||
|
||||
|
||||
async def fetch_read_status_delta(folder, since=None, limit=1000, max_pages=10, token=None):
|
||||
"""GET /sync/read-status -> the raw round dict."""
|
||||
async def fetch_read_status_delta(folder, since=None, limit=100, max_pages=10, token=None):
|
||||
"""GET /sync/read-status -> the raw round dict.
|
||||
|
||||
Upstream Email API caps `limit` at 100; keep the default at that ceiling.
|
||||
"""
|
||||
if not EMAIL_URL:
|
||||
raise RuntimeError("EMAIL_URL must be set")
|
||||
auth_token=token or EMAIL_API_TOKEN
|
||||
if not auth_token:
|
||||
raise RuntimeError("EMAIL_API_TOKEN must be set")
|
||||
params={"folder":folder,"limit":limit,"max_pages":max_pages}
|
||||
params={"folder":folder,"limit":min(int(limit or 100),100),"max_pages":max_pages}
|
||||
if since:
|
||||
params["since"]=since
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
|
|
|
|||
|
|
@ -49,9 +49,14 @@ async def sync_read_status() -> dict:
|
|||
round_data=await fetch_read_status_delta(
|
||||
EMAIL_SYNC_FOLDER,
|
||||
since=since if rounds==1 else None,
|
||||
limit=1000,
|
||||
limit=100,
|
||||
max_pages=10,
|
||||
)
|
||||
except httpx.ConnectError as e:
|
||||
# Email API down / unreachable from this process — soft-fail so the
|
||||
# cron does not burn retries every minute.
|
||||
logger.warning("sync_read_status unreachable: %s",e)
|
||||
return {"error":"unreachable","detail":str(e)}
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code==401:
|
||||
logger.warning("sync_read_status 401 — device-code sign-in required")
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ class Email:
|
|||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def get_email_by_id(self,message_id):
|
||||
async def get_email_by_id(self,message_id,test_on=True):
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
response=await client.get(f"{self.get_url}/emails/{message_id}",
|
||||
|
|
@ -63,6 +63,8 @@ class Email:
|
|||
row,new_user_email=await Inbox_Messages.insert_email(session=self.session,email_data=data,file_path=re_create_file)
|
||||
if row.attachment and row.file_path and row.match_status is None:
|
||||
self.pending_match_ids.append(str(row.id))
|
||||
if test_on:
|
||||
return data
|
||||
if new_user_email:
|
||||
self.pending_confirmation_emails.append(new_user_email)
|
||||
return data
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from fastapi import APIRouter,Depends,Query
|
|||
from fastapi.responses import JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from db_setup import get_session
|
||||
from job.candidate.views import CandidateScoring,FileRead
|
||||
from job.candidate.views import CandidateScoring,FileRead,CandidateView
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from job.job_post.views import JobPost,JobPostCreate
|
||||
|
|
@ -14,6 +14,7 @@ from fastapi import UploadFile, File, Form
|
|||
from pydantic import BaseModel
|
||||
from dotenv import load_dotenv
|
||||
from datetime import datetime, time, timezone
|
||||
|
||||
load_dotenv()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -146,8 +147,8 @@ async def score_inbox_candidates(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/fetch")
|
||||
async def fetch_candidates(
|
||||
@router.get("/candidate/scored/fetch")
|
||||
async def fetch_scored_candidates(
|
||||
job_id: str = Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
|
|
@ -199,3 +200,25 @@ async def fetch_candidate_by_id(
|
|||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/candidate/fetch")
|
||||
async def fetch_candidate(
|
||||
user_id:str=Query(None),
|
||||
limit:int=Query(10),
|
||||
offset:int=Query(0),
|
||||
search:str=Query(None),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=CandidateView(session=session)
|
||||
data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search)
|
||||
# total is the RESULT-SET size, not len(data) — a pager cannot be driven
|
||||
# off the page length. By id stays 1, per the house envelope.
|
||||
total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
from inbox.models import Inbox
|
||||
from typing import Any,List,Dict
|
||||
|
||||
|
||||
def serialize_candidate(row) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
|
|
@ -23,3 +27,32 @@ def serialize_candidate(row) -> dict:
|
|||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]]) -> Dict[str,Any]|List[Dict[str,Any]]:
|
||||
if isinstance(link,list):
|
||||
return [serialize_candidate_profile(item) for item in link]
|
||||
if isinstance(link,dict):
|
||||
return link
|
||||
|
||||
user = link.user
|
||||
message = link.messages
|
||||
return {
|
||||
"inbox_id": link.id,
|
||||
"user_id": str(link.user_id) if link.user_id else None,
|
||||
"name": user.name if user else None,
|
||||
"email": user.email if user else None,
|
||||
"is_active": user.is_active if user else None,
|
||||
"message_id": str(link.message_id) if link.message_id else None,
|
||||
"created_at": link.created_at.isoformat() if link.created_at else None,
|
||||
"application_status": message.application_status if message else None,
|
||||
"experience": message.experience if message else None,
|
||||
"resume_text": message.resume_text if message else None,
|
||||
"suggested_job_post_ids": list(message.suggested_job_post_ids or []) if message else [],
|
||||
"match_summary": message.match_summary if message else None,
|
||||
"match_reasoning": message.match_reasoning if message else None,
|
||||
"match_status": message.match_status if message else None,
|
||||
"match_error": message.match_error if message else None,
|
||||
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
|
||||
"job_posts": [],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from app.core.errors import ATSError,ErrorCode
|
|||
from app.models.scoring import CompletedCandidate
|
||||
from app.services.pdf import extract_resume,sanitize_filename
|
||||
from app.services.scoring import score_batch
|
||||
from inbox.models import Inbox_Messages
|
||||
from inbox.models import Inbox_Messages,Inbox
|
||||
from job.candidate.models import Candidates
|
||||
from job.candidate.plugins import (
|
||||
FILE_NOT_FOUND,
|
||||
|
|
@ -18,8 +18,9 @@ from job.candidate.plugins import (
|
|||
get_scoring_settings,
|
||||
normalize_spaced_text,
|
||||
)
|
||||
from job.candidate.serializers import serialize_candidate
|
||||
from job.candidate.serializers import serialize_candidate,serialize_candidate_profile
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.serializers import serialize_job_post
|
||||
|
||||
class FileRead:
|
||||
def __init__(self,session:AsyncSession,filename=None,file=None):
|
||||
|
|
@ -276,12 +277,50 @@ class CandidateScoring:
|
|||
"summary_critique":None,
|
||||
}
|
||||
|
||||
# class CandidateView:
|
||||
# def __init__(self,session:AsyncSession):
|
||||
# self.session=session
|
||||
|
||||
# async def get_candidate(self,user_id=None):
|
||||
# try:
|
||||
# call_func=Inbox_Messages.get_candidate_profile(user_id=user_id)
|
||||
# except Exception as e:
|
||||
# raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
class CandidateView:
|
||||
def __init__(self,session:AsyncSession):
|
||||
self.session=session
|
||||
|
||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None):
|
||||
try:
|
||||
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search)
|
||||
return await self.attach_job_posts(rows)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def count_candidates(self,user_id=None,search=None):
|
||||
try:
|
||||
return await Inbox.count_candidate_profiles(session=self.session,user_id=user_id,search=search)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def get_job_post_by_id(self,record_id,data=None):
|
||||
"""Load full job_posts row and optionally append it onto a candidate payload."""
|
||||
try:
|
||||
job_post_data=await JobPosts.get_job_post_by_id(session=self.session,record_id=record_id)
|
||||
if not job_post_data:
|
||||
return None
|
||||
payload=serialize_job_post(job_post_data)
|
||||
if isinstance(data,dict):
|
||||
data.setdefault("job_posts",[]).append(payload)
|
||||
return payload
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
async def attach_job_posts(self,data):
|
||||
"""Normalize list/single, serialize each record, attach full job_posts rows."""
|
||||
single=not isinstance(data,list)
|
||||
records=[data] if single else list(data or [])
|
||||
enriched=[]
|
||||
for record in records:
|
||||
payload=serialize_candidate_profile(record)
|
||||
payload["job_posts"]=[]
|
||||
for job_id in payload.get("suggested_job_post_ids") or []:
|
||||
await self.get_job_post_by_id(record_id=job_id,data=payload)
|
||||
enriched.append(payload)
|
||||
return enriched[0] if single else enriched
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ def serialize_job_post(row) -> dict:
|
|||
"post_text": row.post_text,
|
||||
"channel_id": row.channel_id,
|
||||
"platform": row.platform,
|
||||
"is_active": row.is_active,
|
||||
"is_deleted": row.is_deleted,
|
||||
"buffer_post_id": row.buffer_post_id,
|
||||
"buffer_external_link": row.buffer_external_link,
|
||||
"buffer_sent_at": row.buffer_sent_at.isoformat() if row.buffer_sent_at else None,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -9,6 +10,9 @@ from sqlmodel import Field, Relationship, SQLModel, select
|
|||
from role.models import Roles
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
|
||||
from inbox.models import Inbox
|
||||
|
||||
class Users(SQLModel, table=True):
|
||||
__tablename__ = "users"
|
||||
|
||||
|
|
@ -16,7 +20,9 @@ class Users(SQLModel, table=True):
|
|||
name: str
|
||||
email: str = Field(unique=True)
|
||||
role_id: int | None = Field(nullable=True, foreign_key="roles.id")
|
||||
role: Roles | None = Relationship(back_populates="users")
|
||||
role: Roles | None = Relationship(back_populates="users",
|
||||
sa_relationship_kwargs={"lazy": "selectin"}
|
||||
)
|
||||
# selectin, not joined: this is a one-to-many, so a joined load would repeat the
|
||||
# user row once per post. Without an explicit strategy the default is a lazy load,
|
||||
# which raises MissingGreenlet the moment anything touches it under asyncio.
|
||||
|
|
@ -24,6 +30,10 @@ class Users(SQLModel, table=True):
|
|||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
inbox: list["Inbox"] = Relationship(
|
||||
back_populates="user",
|
||||
sa_relationship_kwargs={"lazy": "selectin"},
|
||||
)
|
||||
password: str
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
updated_at: datetime = Field(default_factory=datetime.now)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ services:
|
|||
build:
|
||||
context: ./backend
|
||||
container_name: hrms-taskiq-worker
|
||||
working_dir: /app
|
||||
command:
|
||||
[
|
||||
"taskiq",
|
||||
|
|
@ -32,10 +33,14 @@ services:
|
|||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
TASKIQ_QUEUE_NAME: inbox
|
||||
TASKIQ_WORKER_NAME: worker-01
|
||||
# .env uses localhost for the host-side API; containers must reach the host.
|
||||
DB_HOST: host.docker.internal
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://host.docker.internal:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
|
|
@ -49,12 +54,19 @@ services:
|
|||
build:
|
||||
context: ./backend
|
||||
container_name: hrms-taskiq-scheduler
|
||||
working_dir: /app
|
||||
command: ["taskiq", "scheduler", "taskiq_management.broker_setup:scheduler", "inbox.sync_tasks"]
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
environment:
|
||||
PYTHONPATH: /app
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
TASKIQ_QUEUE_NAME: inbox
|
||||
DB_HOST: host.docker.internal
|
||||
EMAIL_URL: http://host.docker.internal:5000
|
||||
BACKEND_URL: http://host.docker.internal:8000
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
/* ============================================================
|
||||
candidates.js — ATS scoring endpoints (backend/job/app.py).
|
||||
candidates.js — candidate endpoints (backend/job/app.py).
|
||||
|
||||
Two data families share this module:
|
||||
- ATS scoring (persisted `candidates` table): listJobs, listCandidates,
|
||||
getCandidate, scoreUploads, scoreInbox, toCandidateView.
|
||||
- Candidate profiles (inbox -> users -> roles join): list, getByUserId,
|
||||
toRows.
|
||||
|
||||
Same conventions as inbox.js: one named export per endpoint, no hooks,
|
||||
camelCase params mapped to snake_case at the call boundary, and every
|
||||
|
|
@ -19,10 +25,10 @@ export function listJobs() {
|
|||
* score-desc, then failed rows.
|
||||
*/
|
||||
export function listCandidates({ jobId } = {}) {
|
||||
return request('/candidate/fetch', { params: { job_id: jobId } })
|
||||
return request('/candidate/scored/fetch', { params: { job_id: jobId } })
|
||||
}
|
||||
|
||||
/** One candidate row by id. Needs candidates.view. 404s on unknown ids. */
|
||||
/** One scored candidate row by id. Needs candidates.view. 404s on unknown ids. */
|
||||
export function getCandidate(candidateId) {
|
||||
return request('/candidate/fetch_by_id', { params: { candidate_id: candidateId } })
|
||||
}
|
||||
|
|
@ -80,3 +86,34 @@ export function toCandidateView(row) {
|
|||
inboxMessageId: row.inbox_message_id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate profiles — the `inbox -> users -> roles` join, restricted server-side
|
||||
* to role_name == CANDIDATE (backend/inbox/models.py:get_candidate_profile).
|
||||
*
|
||||
* Permissioned with require_permission(CANDIDATES_VIEW), so a caller without the
|
||||
* tag gets a 403.
|
||||
*
|
||||
* `search` is an ilike over users.name / users.email only — it does NOT reach
|
||||
* the résumé text or the suggested job titles.
|
||||
*/
|
||||
export function list({ search, limit, offset } = {}) {
|
||||
return request('/candidate/fetch', { params: { search, limit, offset } })
|
||||
}
|
||||
|
||||
/**
|
||||
* One candidate by users.id.
|
||||
*
|
||||
* NOTE the asymmetric response: get_candidate_profile returns a BARE OBJECT
|
||||
* rather than a one-element list when user_id matches exactly one row
|
||||
* (backend/inbox/models.py:68-70). Callers must normalise — see toRows().
|
||||
*/
|
||||
export function getByUserId(userId) {
|
||||
return request('/candidate/fetch', { params: { user_id: userId } })
|
||||
}
|
||||
|
||||
/** `data` is a list on the list path and a bare object on the by-id path. */
|
||||
export function toRows(res) {
|
||||
if (Array.isArray(res?.data)) return res.data
|
||||
return res?.data ? [res.data] : []
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export default function CandidateProfile({ candidate: c, jobTitle, onClose, onAt
|
|||
{c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
|
||||
</div>
|
||||
<div className="ph-tags">
|
||||
{scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>}
|
||||
{c.scoringStatus && (scored ? <Badge className="b-green">Scored</Badge> : <Badge className="b-red">{c.errorCode ?? 'Failed'}</Badge>)}
|
||||
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
|
||||
{c.experience != null && (
|
||||
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* ============================================================
|
||||
/* ============================================================
|
||||
Candidates — the scored-candidate pool, on live backend data.
|
||||
|
||||
Rows come from GET /candidate/fetch (all jobs) via the shared
|
||||
|
|
@ -387,7 +387,8 @@ function Facet({ label, value, onChange, any, options, labels }) {
|
|||
)
|
||||
}
|
||||
|
||||
function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
||||
/** Exported so TalentPool's profile modal can open the same ATS breakdown. */
|
||||
export function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
||||
const recommendation = recommendationOf(c)
|
||||
const recCls = recommendation === 'Strong Match' ? 'recc-strong'
|
||||
: recommendation === 'Potential Match' ? 'recc-potential' : 'recc-weak'
|
||||
|
|
@ -412,7 +413,7 @@ function AtsMatch({ candidate: c, jobTitle, onClose, onProfile }) {
|
|||
</span>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="fw-600" style={{ fontSize: 15 }}>{recommendation}</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name} for {jobTitle}</div>
|
||||
<div style={{ opacity: 0.85, fontSize: 13 }}>{c.name}{jobTitle ? ` for ${jobTitle}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,61 +1,154 @@
|
|||
/* ============================================================
|
||||
Talent Pool — the prototype's card grid, now fed by GET /candidate/fetch.
|
||||
|
||||
The layout, the toolbar, the card and the 8-tab profile modal are the
|
||||
originals, unchanged. Only the data source moved.
|
||||
|
||||
The endpoint returns name, email, experience, application_status and the
|
||||
suggested job title. It has no aiScore, skills, currentCompany, source or
|
||||
department — the agent writes a verdict and prose, not a score, and no
|
||||
résumé-derived skills are persisted. Each record is therefore OVERLAID on a
|
||||
seed candidate: real values win, seed fills the rest, so the card renders
|
||||
exactly as it always did.
|
||||
|
||||
Clicking a card opens CandidateProfile in place. It used to deep-link into
|
||||
/candidates, which stopped resolving once the ids became real user_ids.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
import { AtsMatch } from './Candidates'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import { avatarColor, initials as initialsOf } from '../data/seed'
|
||||
import { avatarColor, departments, initials as initialsOf } from '../data/seed'
|
||||
|
||||
async function fetchPool() {
|
||||
const res = await candidatesApi.listCandidates()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map(candidatesApi.toCandidateView)
|
||||
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
|
||||
const FETCH_LIMIT = 100
|
||||
|
||||
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
|
||||
/**
|
||||
* Candidate_application_Status (backend/inbox/enums.py) -> the seed stage
|
||||
* vocabulary every screen renders. CLOSED is the column default, i.e. untriaged,
|
||||
* so it reads as Applied rather than as an outcome.
|
||||
*/
|
||||
const STAGE_FROM_STATUS = {
|
||||
PENDING: 'Applied', CLOSED: 'Applied', PROCESS: 'Screening',
|
||||
ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected',
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
const res = await candidatesApi.listJobs()
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return rows.map((row) => ({ id: row.id, title: row.title }))
|
||||
/** "6 years" -> 6. The column is free text, so anything unparseable defers to seed. */
|
||||
function years(value) {
|
||||
const n = parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
const SOURCE_LABEL = { upload: 'Upload', inbox: 'Inbox' }
|
||||
/**
|
||||
* One API record overlaid on one seed candidate.
|
||||
*
|
||||
* `id` deliberately stays the SEED id: CandidateProfile joins seed interviews on
|
||||
* c.id and the favourite/advance mutations key off it, so a UUID here would
|
||||
* empty the Interview tab and silently drop those writes. The real identifier
|
||||
* rides along on `userId`.
|
||||
*/
|
||||
function merge(row, template) {
|
||||
const name = row.name || template.name
|
||||
const title = (row.job_posts || []).map((j) => j.title).find(Boolean)
|
||||
const stage = STAGE_FROM_STATUS[row.application_status] || template.stage
|
||||
const experience = years(row.experience)
|
||||
|
||||
return {
|
||||
...template,
|
||||
userId: row.user_id,
|
||||
name,
|
||||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
email: row.email || template.email,
|
||||
experience: experience ?? template.experience,
|
||||
stage,
|
||||
status: stage,
|
||||
currentTitle: title || template.currentTitle,
|
||||
jobTitle: title || template.jobTitle,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `inbox` holds one row per (user, message), so a candidate who mailed us three
|
||||
* times arrives three times. Collapse onto the person before pairing templates,
|
||||
* otherwise one candidate would occupy three cards and three seed identities.
|
||||
*/
|
||||
function buildPool(rows, templates) {
|
||||
if (!templates.length) return []
|
||||
const byPerson = new Map()
|
||||
for (const row of rows) {
|
||||
const key = row.user_id ?? `inbox-${row.inbox_id}`
|
||||
if (!byPerson.has(key)) byPerson.set(key, row)
|
||||
}
|
||||
return [...byPerson.values()].map((row, i) => merge(row, templates[i % templates.length]))
|
||||
}
|
||||
|
||||
export default function TalentPool() {
|
||||
const { toast } = useToast()
|
||||
const navigate = useNavigate()
|
||||
const poolQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchPool })
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
|
||||
const [q, setQ] = useState('')
|
||||
const [jobId, setJobId] = useState('')
|
||||
const { data: templates = [] } = useQuery(seedQuery('candidates'))
|
||||
const updateCandidates = useSeedMutation('candidates')
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [dept, setDept] = useState('')
|
||||
const [profileFor, setProfileFor] = useState(null)
|
||||
const [atsFor, setAtsFor] = useState(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: qk.candidates.list({ limit: FETCH_LIMIT }),
|
||||
queryFn: () => candidatesApi.list({ limit: FETCH_LIMIT }),
|
||||
})
|
||||
|
||||
// Scored candidates only; failed extraction rows are noise in a talent pool.
|
||||
const pool = useMemo(
|
||||
() => (poolQuery.data ?? []).filter((c) => c.scoringStatus === 'completed'),
|
||||
[poolQuery.data],
|
||||
() => buildPool(candidatesApi.toRows(query.data), templates),
|
||||
[query.data, templates],
|
||||
)
|
||||
|
||||
const list = useMemo(
|
||||
() =>
|
||||
pool.filter((c) => {
|
||||
if (jobId && c.jobId !== jobId) return false
|
||||
if (q) {
|
||||
const hay = `${c.name} ${c.currentCompany ?? ''} ${c.matchedSkills.join(' ')}`.toLowerCase()
|
||||
if (!hay.includes(q.toLowerCase())) return false
|
||||
}
|
||||
if (dept && c.department !== dept) return false
|
||||
if (q && !(c.name + c.currentCompany + c.skills.join(' ')).toLowerCase().includes(q.toLowerCase())) return false
|
||||
return true
|
||||
}),
|
||||
[pool, q, jobId],
|
||||
[pool, q, dept],
|
||||
)
|
||||
|
||||
// Both mirror Candidates.jsx so a change made here shows up there too. The
|
||||
// card renders neither favourite nor stage, so only the open modal restates.
|
||||
function toggleFav(c) {
|
||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
|
||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
|
||||
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
|
||||
}
|
||||
|
||||
function advance(c) {
|
||||
const i = STAGE_ORDER.indexOf(c.stage)
|
||||
if (i === -1 || i >= STAGE_ORDER.length - 1) {
|
||||
toast(`${c.name} cannot be advanced further`, 'warning')
|
||||
return
|
||||
}
|
||||
const stage = STAGE_ORDER[i + 1]
|
||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
|
||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p))
|
||||
toast(`${c.name} moved to ${stage}`, 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1 className="page-title">Talent Pool</h1>
|
||||
<p className="page-sub">{pool.length} scored candidate{pool.length === 1 ? '' : 's'} across all jobs</p>
|
||||
<p className="page-sub">{pool.length} silver-medalists & passive candidates to re-engage</p>
|
||||
</div>
|
||||
<div className="page-head-actions">
|
||||
<button className="btn btn-primary" onClick={() => toast('Talent campaign created', 'success')}>
|
||||
|
|
@ -71,66 +164,76 @@ export default function TalentPool() {
|
|||
<Icon name="search" />
|
||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
|
||||
</div>
|
||||
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||
<option value="">All Jobs</option>
|
||||
{(jobsQuery.data ?? []).map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{poolQuery.isPending && (
|
||||
<EmptyState icon="talent" title="Loading…">Fetching scored candidates from the server.</EmptyState>
|
||||
)}
|
||||
{poolQuery.isError && (
|
||||
<EmptyState icon="talent" title="Couldn’t load the talent pool">
|
||||
{friendlyAuthError(poolQuery.error, 'Request failed')}
|
||||
</EmptyState>
|
||||
)}
|
||||
|
||||
{poolQuery.isSuccess && (
|
||||
<div className="grid g-3">
|
||||
{list.length === 0 ? (
|
||||
<div style={{ gridColumn: '1/-1' }}>
|
||||
<EmptyState title="No candidates found">
|
||||
{pool.length === 0
|
||||
? 'Score some resumes in CV Import to build the pool.'
|
||||
: 'Try a different search or job filter.'}
|
||||
<div className="grid g-3">
|
||||
{list.length === 0 ? (
|
||||
<div style={{ gridColumn: '1/-1' }}>
|
||||
{/* Same slot, same component — a failed fetch must not read as "no results". */}
|
||||
{query.isError ? (
|
||||
<EmptyState title="Could not load talent pool">
|
||||
{friendlyAuthError(query.error, 'Please try again.')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
) : (
|
||||
list.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/candidates', { state: { openCandidate: c.id } })}
|
||||
>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ marginBottom: 12 }}>
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} className="avatar-lg" />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="lr-title">{c.name}</div>
|
||||
<div className="lr-sub">{c.currentTitle ?? c.filename}</div>
|
||||
</div>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 12 }}>
|
||||
{c.matchedSkills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub">
|
||||
<Icon name="briefcase" /> {c.experience != null ? `${c.experience} yrs` : '—'}
|
||||
</span>
|
||||
<span className="cell-sub">{c.currentCompany ?? '—'}</span>
|
||||
<Badge className="b-gray">{SOURCE_LABEL[c.source] ?? c.source}</Badge>
|
||||
) : query.isPending ? (
|
||||
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
|
||||
) : (
|
||||
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
list.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setProfileFor(c)}
|
||||
>
|
||||
<div className="card-body">
|
||||
<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-sub">{c.currentTitle}</div>
|
||||
</div>
|
||||
<ScoreChip score={c.aiScore} />
|
||||
</div>
|
||||
<div className="k-tags" style={{ marginBottom: 12 }}>
|
||||
{c.skills.slice(0, 4).map((s) => <span className="tag" key={s}>{s}</span>)}
|
||||
</div>
|
||||
<div className="divider" style={{ margin: '12px 0' }} />
|
||||
<div className="flex items-center" style={{ justifyContent: 'space-between' }}>
|
||||
<span className="cell-sub"><Icon name="briefcase" /> {c.experience} yrs</span>
|
||||
<span className="cell-sub">{c.currentCompany}</span>
|
||||
<Badge className="b-gray">{c.source}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{atsFor && (
|
||||
<AtsMatch
|
||||
candidate={atsFor}
|
||||
onClose={() => setAtsFor(null)}
|
||||
onProfile={(c) => { setAtsFor(null); setProfileFor(c) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{profileFor && (
|
||||
<CandidateProfile
|
||||
candidate={profileFor}
|
||||
onClose={() => setProfileFor(null)}
|
||||
onAdvance={advance}
|
||||
onToggleFav={toggleFav}
|
||||
onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue