diff --git a/backend/Dockerfile b/backend/Dockerfile index e462a2e..3a584e1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 6d4321e..9f279c5 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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)) diff --git a/backend/inbox/decoded_attachments/Mehdi Raza Content Writing Samples 001.docx b/backend/inbox/decoded_attachments/Mehdi Raza Content Writing Samples 001.docx new file mode 100644 index 0000000..96dd43c Binary files /dev/null and b/backend/inbox/decoded_attachments/Mehdi Raza Content Writing Samples 001.docx differ diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 97f9699..d1e711c 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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 diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 5176f49..9548c6d 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -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: diff --git a/backend/inbox/sync_tasks.py b/backend/inbox/sync_tasks.py index 42dc6ac..42cc8e9 100644 --- a/backend/inbox/sync_tasks.py +++ b/backend/inbox/sync_tasks.py @@ -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") diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 8c6092d..ea6a12d 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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 diff --git a/backend/job/app.py b/backend/job/app.py index 1117c4f..9585320 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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)) diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 5f45d4b..2c450af 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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": [], + } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index aed83bb..4312836 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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)) \ No newline at end of file + +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 diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 4589f3d..d58e3ad 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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, diff --git a/backend/users/models.py b/backend/users/models.py index cacf22d..bbfa0a1 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index be849d0..7f99556 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index eff3388..3b8bdc9 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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] : [] +} diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 839ead5..bf56028 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -40,7 +40,7 @@ export default function CandidateProfile({ candidate: c, jobTitle, onClose, onAt {c.currentTitle ?? '—'}{c.currentCompany ? ` at ${c.currentCompany}` : ''}
- {scored ? Scored : {c.errorCode ?? 'Failed'}} + {c.scoringStatus && (scored ? Scored : {c.errorCode ?? 'Failed'})} {SOURCE_LABEL[c.source] ?? c.source} {c.experience != null && ( {c.experience} yrs exp diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 0b77483..d91de41 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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 }) {
{recommendation}
-
{c.name} for {jobTitle}
+
{c.name}{jobTitle ? ` for ${jobTitle}` : ''}
diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 68171ad..be2105f 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -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 (

Talent Pool

-

{pool.length} scored candidate{pool.length === 1 ? '' : 's'} across all jobs

+

{pool.length} silver-medalists & passive candidates to re-engage

- setDept(e.target.value)}> + + {departments.map((d) => )}
- {poolQuery.isPending && ( - Fetching scored candidates from the server. - )} - {poolQuery.isError && ( - - {friendlyAuthError(poolQuery.error, 'Request failed')} - - )} - - {poolQuery.isSuccess && ( -
- {list.length === 0 ? ( -
- - {pool.length === 0 - ? 'Score some resumes in CV Import to build the pool.' - : 'Try a different search or job filter.'} +
+ {list.length === 0 ? ( +
+ {/* Same slot, same component — a failed fetch must not read as "no results". */} + {query.isError ? ( + + {friendlyAuthError(query.error, 'Please try again.')} -
- ) : ( - list.map((c) => ( -
navigate('/candidates', { state: { openCandidate: c.id } })} - > -
-
- -
-
{c.name}
-
{c.currentTitle ?? c.filename}
-
- -
-
- {c.matchedSkills.slice(0, 4).map((s) => {s})} -
-
-
- - {c.experience != null ? `${c.experience} yrs` : '—'} - - {c.currentCompany ?? '—'} - {SOURCE_LABEL[c.source] ?? c.source} + ) : query.isPending ? ( + Fetching candidates. + ) : ( + Try a different search or department. + )} +
+ ) : ( + list.map((c) => ( +
setProfileFor(c)} + > +
+
+ +
+
{c.name}
+
{c.currentTitle}
+ +
+
+ {c.skills.slice(0, 4).map((s) => {s})} +
+
+
+ {c.experience} yrs + {c.currentCompany} + {c.source}
- )) - )} -
+
+ )) + )} +
+ + {atsFor && ( + setAtsFor(null)} + onProfile={(c) => { setAtsFor(null); setProfileFor(c) }} + /> + )} + + {profileFor && ( + setProfileFor(null)} + onAdvance={advance} + onToggleFav={toggleFav} + onAtsMatch={(c) => { setProfileFor(null); setAtsFor(c) }} + /> )}
)