LINK_X_USER_INBOX #9

Merged
ahmed.mujtaba merged 7 commits from LINK_X_USER_INBOX into main 2026-08-11 14:04:31 +00:00
41 changed files with 3202 additions and 104 deletions

View File

@ -0,0 +1,75 @@
"""Employment response decorators for `parse_employment_response`.
Pure module: no FastAPI imports, no HTTPException, and no module-level state.
Mirrors job/candidate/decorators.py stacked wrappers that clean LLM output
before the task persists it:
raw JSON -> require_json_object -> clamp_company_to_resume
-> clamp_education_to_resume -> parse_employment_response
"""
from __future__ import annotations
from functools import wraps
from employment_agent.prompt import EDUCATION,NO_COMPANY
def require_json_object(func):
"""Reject non-dict LLM payloads before field parsing runs."""
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
if not isinstance(data,dict):
raise RuntimeError(f"model did not return a JSON object: {data!r}")
return func(data,resume_text,*args,**kwargs)
return wrapper
def clamp_company_to_resume(func):
"""Keep company only when it appears in resume_text; else NO_COMPANY."""
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education=func(data,resume_text,*args,**kwargs)
company=(company or "").strip()
if not company or company.lower()==NO_COMPANY.lower():
return NO_COMPANY,education
haystack=(resume_text or "").lower()
if company.lower() not in haystack:
return NO_COMPANY,education
return company,education
return wrapper
def clamp_education_to_resume(func):
"""Keep education only when it appears in resume_text; else EDUCATION."""
@wraps(func)
def wrapper(data,resume_text="",*args,**kwargs):
company,education=func(data,resume_text,*args,**kwargs)
education=(education or "").strip()
if not education or education.lower()==EDUCATION.lower():
return company,EDUCATION
haystack=(resume_text or "").lower()
if education.lower() not in haystack:
return company,EDUCATION
return company,education
return wrapper
@require_json_object
@clamp_company_to_resume
@clamp_education_to_resume
def parse_employment_response(data,resume_text:str="") -> tuple[str,str]:
"""Pull company + education from LLM JSON; decorators clamp to the resume."""
current=data.get("current_employment")
education=data.get("education")
if not isinstance(current,str):
current=""
if not isinstance(education,str):
education=""
return current.strip(),education.strip()

View File

@ -0,0 +1,27 @@
"""Employment extraction entrypoint — llm_setup.llm_call only.
Pure module: no FastAPI imports and no HTTPException.
Called from inbox.tasks.match_inbox_message; no HTTP surface.
"""
from __future__ import annotations
import logging
from employment_agent.decorators import parse_employment_response
from employment_agent.prompt import EDUCATION,NO_COMPANY,prompt,user_prompt
from llm_setup import llm_call
logger=logging.getLogger("employment_agent")
async def run_employment_agent(*,resume_text="") -> tuple[str,str]:
text=(resume_text or "").strip()
if not text:
return NO_COMPANY,EDUCATION
try:
data=await llm_call(prompt(),user_prompt(text),json_mode=True)
return parse_employment_response(data,text)
except Exception as e:
logger.exception("employment llm_call failed")
raise RuntimeError(str(e)) from e

View File

@ -0,0 +1,37 @@
"""Employment LLM prompt builders.
Pure module: no FastAPI imports and no HTTPException.
"""
from __future__ import annotations
import json
NO_COMPANY="no company was mentioned"
EDUCATION="No Education Mentioned"
def prompt():
return f"""You are an HR-ATS recruiting assistant.
You are given CV/resume text. Identify the candidate's CURRENT employer company
name and their education (degree / school) when present.
Rules:
- Return only the company name that appears in the resume text for the ongoing / most recent role.
- Return only education that appears in the resume text.
- The company string you return MUST appear verbatim (or as a clear substring) in the resume text.
- The education string you return MUST appear verbatim (or as a clear substring) in the resume text.
- Do not invent a company. If none is mentioned, return exactly: {NO_COMPANY}
- Do not invent education. If none is mentioned, return exactly: {EDUCATION}
Respond with JSON only:
{{
"current_employment": "Company Name",
"education": "Degree / School"
}}
"""
def user_prompt(resume_text:str) -> str:
return json.dumps({"resume_text":resume_text or ""},ensure_ascii=False)

View File

@ -1,6 +1,7 @@
from fastapi import APIRouter,Depends, Query
from fastapi.responses import JSONResponse
from fastapi import HTTPException
from pydantic import BaseModel
from db_setup import get_session
from inbox.enums import Candidate_application_Status
from sqlalchemy.ext.asyncio import AsyncSession
@ -11,6 +12,10 @@ load_dotenv()
router = APIRouter()
class AssignJobPostBody(BaseModel):
job_post_id: str | None = None
@router.get("/email/fetch")
async def fetch_email(
top:int=Query(100),
@ -88,6 +93,23 @@ async def rematch_inbox(
raise HTTPException(status_code=500,detail=str(e))
@router.patch("/inbox/{record_id}/assign-job-post")
async def assign_job_post(
record_id: str,
payload: AssignJobPostBody,
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Email(session=session)
data=await service.assign_job_post(record_id,payload.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.post("/inbox/{record_id}/read")
async def mark_inbox_read(
record_id: str,
@ -125,6 +147,7 @@ async def get_all_applications(
record_id: str | None = Query(None),
application_status: Candidate_application_Status = Query(default=Candidate_application_Status.CLOSED),
isread: bool = Query(default=True),
assigned: bool | None = Query(default=None),
search: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0, ge=0),
@ -134,22 +157,22 @@ async def get_all_applications(
try:
service=Email(session=session)
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
items=await service.get_all_applications(top, skip, search, application_status=application_status)
total=await service.count_inbox_messages(search, application_status=application_status)
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False)
total=await service.count_inbox_messages(search, isread=False)
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if record_id:
item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_all_applications(top,skip,search)
total=await service.count_inbox_messages(search)
items=await service.get_all_applications(top,skip,search,assigned=assigned)
total=await service.count_inbox_messages(search,assigned=assigned)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
raise HTTPException(status_code=500,detail=str(e))

View File

@ -8,4 +8,9 @@ class Candidate_application_Status(str, Enum):
APPROVED="APPROVED"
REJECTED="REJECTED"
ONHOLD="ONHOLD"
CLOSED="CLOSED"
CLOSED="CLOSED"
SCREENING="SCREENING"
ASSESSMENT="ASSESSMENT"
INTERVIEW="INTERVIEW"
OFFER="OFFER"
HIRED="HIRED"

View File

@ -2,7 +2,7 @@ import logging
import os
import uuid
from datetime import datetime, timezone
from typing import Any, Optional
from typing import Any, List, Optional
from dotenv import load_dotenv
from fastapi import HTTPException
@ -15,6 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select, true
from job.candidate.models import Activity, Feedback, Interviews
from users.models import Users
from users.plugins import hash_password
@ -44,7 +45,23 @@ class Inbox(SQLModel, table=True):
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
user: Optional[Users] = Relationship(
favorite: Optional[bool] = Field(default=False)
rating: Optional[float] = Field(default=0.0)
# 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"},
)
activity: List["Activity"] = Relationship(
back_populates="inbox",
sa_relationship_kwargs={"lazy": "selectin"},
)
feedback: List["Feedback"] = Relationship(
back_populates="inbox",
sa_relationship_kwargs={"lazy": "selectin"},
)
user: Optional["Users"] = Relationship(
back_populates="inbox",
sa_relationship_kwargs={"lazy": "joined"},
)
@ -57,9 +74,16 @@ class Inbox(SQLModel, table=True):
@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:
options=[selectinload(cls.messages)]
if user_id:
options.extend([
selectinload(cls.interviews),
selectinload(cls.activity),
selectinload(cls.feedback),
])
qry = (
select(cls)
.options(selectinload(cls.messages))
.options(*options)
.join(Users, cls.user_id == Users.id)
.join(Roles, Users.role_id == Roles.id)
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
@ -97,6 +121,52 @@ class Inbox(SQLModel, table=True):
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def get_inbox_by_id(cls,session:AsyncSession,record_id:int|str|None):
if record_id is None:
return None
try:
iid=int(record_id)
except (TypeError,ValueError):
return None
result=await session.execute(select(cls).where(cls.id==iid))
return result.scalars().first()
@classmethod
async def get_inbox_by_message_id(cls,session:AsyncSession,message_id):
try:
mid=uuid.UUID(str(message_id))
except ValueError:
return None
result=await session.execute(
select(cls).where(cls.message_id==mid).order_by(cls.created_at.desc())
)
return result.scalars().first()
@classmethod
async def get_inbox_by_user_id(cls,session:AsyncSession,user_id):
try:
uid=uuid.UUID(str(user_id))
except ValueError:
return None
result=await session.execute(
select(cls).where(cls.user_id==uid).order_by(cls.created_at.desc())
)
return result.scalars().first()
@classmethod
async def update_inbox(cls,session:AsyncSession,record_id,fields:dict):
row=await cls.get_inbox_by_id(session,record_id)
if not row:
return None
for key,value in fields.items():
setattr(row,key,value)
row.updated_at=datetime.now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
class Inbox_Alerts(SQLModel, table=True):
__tablename__ = "inbox_alerts"
@ -135,12 +205,16 @@ class Inbox_Messages(SQLModel, table=True):
resume_text: str | None = Field(default=None)
experience: str | None = Field(default=None)
suggested_job_post_ids: list[str] | None = Field(default=None, sa_column=Column(JSONB))
assigned_job_post_id: uuid.UUID | None = Field(default=None, foreign_key="job_posts.id", index=True)
match_summary: str | None = Field(default=None)
match_reasoning: str | None = Field(default=None)
match_status: str | None = Field(default=None)
match_error: str | None = Field(default=None)
matched_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
candidate_phone_number: str | None = Field(default="xxx-xxx-xxxx")
candidate_education: str | None = Field(default=None)
current_employment: str | None = Field(default=None)
inbox: list[Inbox] = Relationship(back_populates="messages")
@staticmethod
@ -172,6 +246,9 @@ class Inbox_Messages(SQLModel, table=True):
*,
resume_text=None,
experience=None,
candidate_education=None,
candidate_phone_number=None,
current_employment=None,
suggested_job_post_ids=None,
summary="",
reasoning="",
@ -184,6 +261,12 @@ class Inbox_Messages(SQLModel, table=True):
return None
if resume_text is not None:
row.resume_text = resume_text
if candidate_phone_number is not None:
row.candidate_phone_number = candidate_phone_number
if candidate_education is not None:
row.candidate_education = candidate_education
if current_employment is not None:
row.current_employment = current_employment
row.suggested_job_post_ids = suggested_job_post_ids
row.match_summary = summary or None
row.match_reasoning = reasoning or None
@ -350,7 +433,7 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod
async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None
):
statement = select(cls).order_by(cls.message_received_time.desc())
if search:
@ -358,13 +441,18 @@ class Inbox_Messages(SQLModel, table=True):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if assigned is True:
statement = statement.where(cls.assigned_job_post_id.is_not(None))
elif assigned is False:
statement = statement.where(cls.assigned_job_post_id.is_(None))
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement)
@ -380,12 +468,34 @@ class Inbox_Messages(SQLModel, table=True):
return result.scalars().first()
@classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED):
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."""
row = await cls.get_inbox_message_by_id(session, record_id)
if not row:
return None
if job_post_id is None:
row.assigned_job_post_id = None
else:
try:
row.assigned_job_post_id = uuid.UUID(str(job_post_id))
except ValueError:
return None
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None):
statement = select(func.count()).select_from(cls)
if search:
statement = statement.where(cls._search_filter(search))
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if assigned is True:
statement = statement.where(cls.assigned_job_post_id.is_not(None))
elif assigned is False:
statement = statement.where(cls.assigned_job_post_id.is_(None))
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement)

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import base64
import os
import re
from pathlib import Path
from urllib.parse import quote
@ -20,6 +21,11 @@ EMAIL_API_TOKEN=os.getenv("EMAIL_API_TOKEN")
BACKEND_URL=os.getenv("BACKEND_URL","http://localhost:8000")
_ATTACHMENTS_DIR=Path(__file__).resolve().parent/"decoded_attachments"
# Prefer +92 / 03xx style numbers; fall back to a looser intl-ish pattern.
_PHONE=re.compile(
r"(?:\+?92[\s\-]?)?0?3\d{2}[\s\-]?\d{7}"
r"|(?:\+?\d{1,3}[\s\-]?)?(?:\(?\d{2,4}\)?[\s\-]?)?\d{3,4}[\s\-]?\d{3,4}"
)
async def request_email_confirmation(email):
@ -127,6 +133,13 @@ def load_message_files(message:Inbox_Messages) -> list[dict]:
return files
def extract_phone(text:str) -> str|None:
m=_PHONE.search(text or "")
if not m:
return None
return re.sub(r"[\s\-()]+"," ",m.group(0)).strip()
async def extract_resume_text(file_paths:list[str]) -> tuple[str,str]:
candidates=[resolve_attachment_path(p) for p in (file_paths or []) if p and p.strip()]
existing=[p for p in candidates if p.is_file() and p.suffix.lower()==".pdf"]

View File

@ -61,11 +61,13 @@ def serialize_message(message: Inbox_Messages) -> dict:
"message_reply": message.message_reply,
"file_path": message.file_path,
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
"match_summary": message.match_summary,
"match_reasoning": message.match_reasoning,
"match_status": message.match_status,
"match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
"resume_text": message.resume_text,
}
@ -76,10 +78,10 @@ def serialize_application(message: Inbox_Messages) -> dict:
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
processing state beyond read/unread. inbox_messages has no columns for any of
those, so they come back null instead of invented see the note in
inbox/file_decoder.py. `processing` is derived from message_read alone, so it
is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
processing state beyond read/unread. phone comes from candidate_phone_number
(filled by the match task); ats_score/recruiter/duplicate stay null until
columns exist. `processing` is derived from message_read alone, so it is only
ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
"""
return {
"id": str(message.id),
@ -95,9 +97,17 @@ def serialize_application(message: Inbox_Messages) -> dict:
"attachment": _attachment_name(message),
"has_attachment": message.attachment,
"resume_text": message.resume_text,
"suggested_job_post_ids": list(message.suggested_job_post_ids or []),
"assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None,
"match_summary": message.match_summary,
"match_reasoning": message.match_reasoning,
"match_status": message.match_status,
"match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
"ats_score": None,
"phone": None,
"phone": message.candidate_phone_number,
"experience": message.experience or "",
"current_employment": message.current_employment or "",
"recruiter": None,
"duplicate": None,
}

View File

@ -7,8 +7,9 @@ from datetime import datetime,timezone
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.plugins import extract_resume_text
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
from taskiq_management.broker_setup import MAX_RETRIES,RETRY_DELAY,broker
@ -50,6 +51,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
job_posts=[serialize_job_post(p) for p in posts]
text,extract_err=await extract_resume_text(paths)
phone=extract_phone(text) if text else None
if not text:
async with session_scope() as session:
await Inbox_Messages.set_match_result(
@ -62,16 +64,26 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
if status=="failed":
raise RuntimeError(result.get("error") or "agent returned failed status")
current_employment,education=await run_employment_agent(resume_text=text)
async with session_scope() as session:
await Inbox_Messages.set_match_result(
session,
record_id,
resume_text=text,
experience=result.get("experience") or "",
candidate_phone_number=phone,
current_employment=current_employment,
candidate_education=education,
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
summary=result.get("summary") or "",
reasoning=result.get("reasoning") or "",
status=status,
error=result.get("error") or "",
)
return {"status":status,"suggested_job_post_ids":result.get("suggested_job_post_ids") or []}
return {
"status":status,
"suggested_job_post_ids":result.get("suggested_job_post_ids") or [],
"current_employment":current_employment,
"education":education,
}

View File

@ -92,15 +92,33 @@ class Email:
files=load_message_files(message)
if files:
item["files"]=files
from job.candidate.views import CandidateView
cv=CandidateView(session=self.session)
suggested=[]
for job_id in item.get("suggested_job_post_ids") or []:
jp=await cv.get_job_post_by_id(record_id=job_id)
if jp:
if jp.get("is_deleted") or not jp.get("is_active"):
suggested.append({**jp,"unavailable":True})
else:
suggested.append(jp)
else:
suggested.append({"id":str(job_id),"unavailable":True})
item["suggested_job_posts"]=suggested
assigned_id=item.get("assigned_job_post_id")
if assigned_id:
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
else:
item["assigned_job_post"]=None
return item
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status)
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None):
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED):
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned)
elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned)
else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned)
return [serialize_application(m) for m in messages]
async def get_application_by_id(self,record_id):
@ -141,13 +159,27 @@ class Email:
results.append({"email":email,"sent":False})
return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status)
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned)
elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False)
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned)
else:
return await Inbox_Messages.count_inbox_messages(self.session,search)
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned)
async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
raise HTTPException(status_code=404,detail="Message not found")
if job_post_id is not None:
from job.job_post.models import JobPosts
post=await JobPosts.get_job_post_by_id(self.session,job_post_id)
if not post or post.is_deleted or not post.is_active:
raise HTTPException(status_code=422,detail="Job post is missing, deleted, or inactive")
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
if not updated:
raise HTTPException(status_code=404,detail="Message not found")
return await self.get_inbox_message_by_id(record_id)
async def mark_read(self,record_id):
message=await Inbox_Messages.mark_message_read(self.session,record_id)

View File

@ -0,0 +1,10 @@
def serialize_activity(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"activity_type": row.activity_type,
"activity_date": row.activity_date.isoformat() if row.activity_date else None,
"activity_time": row.activity_time.isoformat() if row.activity_time else None,
"activity_status": row.activity_status,
"description": row.description,
}

View File

@ -0,0 +1,55 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.models import Inbox
from job.candidate.models import Activity
from job.activity.serializers import serialize_activity
class ActivityLog:
def __init__(self,session:AsyncSession):
self.session=session
async def _resolve_inbox(self,payload):
if payload.get("inbox_id") is not None:
row=await Inbox.get_inbox_by_id(self.session,payload["inbox_id"])
if not row:
raise HTTPException(status_code=404,detail="Inbox not found")
return row
if payload.get("message_id"):
row=await Inbox.get_inbox_by_message_id(self.session,payload["message_id"])
if not row:
raise HTTPException(status_code=404,detail="Inbox not found for message_id")
return row
if payload.get("user_id"):
row=await Inbox.get_inbox_by_user_id(self.session,payload["user_id"])
if not row:
raise HTTPException(status_code=404,detail="Inbox not found for user_id")
return row
return None
async def get_activity(self,activity_id=None,inbox_id=None):
if activity_id:
row=await Activity.get_activity_by_id(self.session,activity_id)
if not row:
raise HTTPException(status_code=404,detail="Activity not found")
return serialize_activity(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="activity_id or inbox_id is required")
rows=await Activity.get_activity_by_inbox(self.session,int(inbox_id))
return [serialize_activity(r) for r in rows]
async def create_activity(self,payload):
link=await self._resolve_inbox(payload)
fields={
"activity_type":payload.get("activity_type") or "",
"activity_status":payload.get("activity_status") or "",
"description":payload.get("description"),
"inbox_id":link.id if link else None,
}
if payload.get("activity_date") is not None:
fields["activity_date"]=payload["activity_date"]
if payload.get("activity_time") is not None:
fields["activity_time"]=payload["activity_time"]
row=await Activity.insert_activity(self.session,fields)
return serialize_activity(row)

View File

@ -3,6 +3,10 @@ from fastapi.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from job.candidate.views import FileRead,CandidateView
from job.interviews.views import Interview
from job.notes.views import Note
from job.activity.views import ActivityLog
from job.feedback.views import FeedbackView
from sqlalchemy.ext.asyncio import AsyncSession
from users.permissions import PermissionTag, require_permission
from job.job_post.views import JobPost,JobPostCreate
@ -11,6 +15,8 @@ from job.job_post.plugins import PlatformAlias
from fastapi import UploadFile, File
from dotenv import load_dotenv
from datetime import datetime, time, timezone
from pydantic import BaseModel
from uuid import UUID
load_dotenv()
logging.basicConfig(level=logging.INFO)
@ -19,6 +25,65 @@ logger = logging.getLogger(__name__)
router = APIRouter()
class CandidateUpdate(BaseModel):
favorite: bool | None = None
rating: float | None = None
class InterviewCreate(BaseModel):
inbox_id: int
interview_date: datetime | None = None
interview_time: datetime | None = None
interview_type: str | None = None
interview_status: str | None = None
class InterviewUpdate(BaseModel):
interview_date: datetime | None = None
interview_time: datetime | None = None
interview_type: str | None = None
interview_status: str | None = None
inbox_id: int | None = None
class NoteCreate(BaseModel):
user_id: UUID
note: str
class NoteUpdate(BaseModel):
note: str | None = None
class ActivityCreate(BaseModel):
message_id: UUID | None = None
user_id: UUID | None = None
inbox_id: int | None = None
activity_type: str | None = None
activity_status: str | None = None
description: str | None = None
activity_date: datetime | None = None
activity_time: datetime | None = None
class FeedbackCreate(BaseModel):
inbox_id: int | None = None
review: str | None = None
financial_status: str | None = None
score: float | None = None
note: str | None = None
reviewed_by: UUID | None = None
class FeedbackUpdate(BaseModel):
review: str | None = None
financial_status: str | None = None
score: float | None = None
note: str | None = None
inbox_id: int | None = None
reviewed_by: UUID | None = None
@router.get("/jobs/alias")
async def get_job_alias():
@ -103,6 +168,33 @@ async def buffer_channels(
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@router.get("/job/fetch")
async def fetch_job_posts(
search: str | None = Query(None),
top: int | None = Query(None),
skip: int = Query(0, ge=0),
ids: str | None = Query(None),
active_only: bool = Query(True),
current_user: dict = Depends(require_permission(PermissionTag.JOB_BOARD_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=JobPost(session=session)
id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None
data,total=await service.fetch_job_posts(
search=search,
top=top,
skip=skip,
ids=id_list,
active_only=active_only,
)
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))
@router.get("/candidate/fetch")
async def fetch_candidate(
user_id:str=Query(None),
@ -115,11 +207,215 @@ async def fetch_candidate(
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))
@router.patch("/candidate/update")
async def update_candidate(
user_id:str=Query(...),
payload:CandidateUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=CandidateView(session=session)
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True))
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("/interview/fetch")
async def fetch_interview(
interview_id:str=Query(None),
inbox_id:int=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.get_interview(interview_id=interview_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
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))
@router.post("/interview/create")
async def create_interview(
payload:InterviewCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.create_interview(payload.model_dump(exclude_unset=True))
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.patch("/interview/update")
async def update_interview(
interview_id:str=Query(...),
payload:InterviewUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Interview(session=session)
data=await service.update_interview(interview_id,payload.model_dump(exclude_unset=True))
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("/notes/fetch")
async def fetch_notes(
note_id:str=Query(None),
user_id:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.get_note(note_id=note_id,user_id=user_id)
total=1 if isinstance(data,dict) else len(data)
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))
@router.post("/notes/create")
async def create_note(
payload:NoteCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
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.patch("/notes/update")
async def update_note(
note_id:str=Query(...),
payload:NoteUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Note(session=session)
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True))
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("/activity/fetch")
async def fetch_activity(
activity_id:str=Query(None),
inbox_id:int=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=ActivityLog(session=session)
data=await service.get_activity(activity_id=activity_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
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))
@router.post("/activity/create")
async def create_activity(
payload:ActivityCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=ActivityLog(session=session)
data=await service.create_activity(payload.model_dump(exclude_unset=True))
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("/feedback/fetch")
async def fetch_feedback(
feedback_id:str=Query(None),
inbox_id:int=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.get_feedback(feedback_id=feedback_id,inbox_id=inbox_id)
total=1 if isinstance(data,dict) else len(data)
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))
@router.post("/feedback/create")
async def create_feedback(
payload:FeedbackCreate,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.create_feedback(payload.model_dump(exclude_unset=True),current_user)
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.patch("/feedback/update")
async def update_feedback(
feedback_id:str=Query(...),
payload:FeedbackUpdate=...,
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=FeedbackView(session=session)
data=await service.update_feedback(feedback_id,payload.model_dump(exclude_unset=True))
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))

View File

@ -1,6 +1,266 @@
# from sqlmodel import SQLModel, Field
# from uuid import UUID, uuid4
# from datetime import datetime
# from enum import Enum
import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, List, Optional
# class CV_extraction(SQLModel,table=True):
from sqlalchemy import DateTime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
if TYPE_CHECKING:
from inbox.models import Inbox
from users.models import Users
def _now() -> datetime:
return datetime.now(timezone.utc)
# Every datetime below is aware (see _now, and the API parses ISO input carrying
# an offset), so each column is declared timestamptz. SQLModel maps a bare
# `datetime` to TIMESTAMP WITHOUT TIME ZONE, and asyncpg refuses to bind an aware
# value to one — "can't subtract offset-naive and offset-aware datetimes" — which
# turns every insert here into a 500. Same pairing as job/job_post/models.py.
class Interviews(SQLModel, table=True):
__tablename__ = "interviews"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
interview_date: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
interview_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
interview_type: str = Field(default="")
interview_status: str = Field(default="")
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
inbox: Optional["Inbox"] = Relationship(
back_populates="interviews",
sa_relationship_kwargs={"lazy": "selectin"},
)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_interview_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()
@classmethod
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls).where(cls.inbox_id == inbox_id).order_by(cls.interview_date.desc())
)
return result.scalars().all()
@classmethod
async def insert_interview(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_interview_by_id(session, row.id)
@classmethod
async def update_interview(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_interview_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
session.add(row)
await session.commit()
await session.refresh(row)
return row
class Notes(SQLModel, table=True):
__tablename__ = "notes"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
note: str = Field(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))
user_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
user: Optional["Users"] = Relationship(
back_populates="notes",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"},
)
author: Optional["Users"] = Relationship(
back_populates="authored_notes",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"},
)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_note_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()
@classmethod
async def get_notes_by_user(cls, session: AsyncSession, user_id):
uid = cls._as_uuid(user_id)
if uid is None:
return []
result = await session.execute(
select(cls).where(cls.user_id == uid).order_by(cls.created_at.desc())
)
return result.scalars().all()
@classmethod
async def insert_note(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_note_by_id(session, row.id)
@classmethod
async def update_note(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_note_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
class Activity(SQLModel, table=True):
__tablename__ = "activity"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
activity_type: str = Field(default="")
activity_date: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
activity_time: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
activity_status: str = Field(default="")
description: str | None = Field(default=None)
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
inbox: Optional["Inbox"] = Relationship(
back_populates="activity",
sa_relationship_kwargs={"lazy": "selectin"},
)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_activity_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()
@classmethod
async def get_activity_by_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls).where(cls.inbox_id == inbox_id).order_by(cls.activity_date.desc())
)
return result.scalars().all()
@classmethod
async def insert_activity(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_activity_by_id(session, row.id)
@classmethod
async def update_activity(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_activity_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
session.add(row)
await session.commit()
await session.refresh(row)
return row
class Feedback(SQLModel, table=True):
__tablename__ = "feedback"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
review: str = Field(default="")
financial_status: str = Field(default="")
score: float = Field(default=0.0)
note: str | None = Field(default=None)
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
reviewed_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
inbox_id: int | None = Field(default=None, foreign_key="inbox.id")
user: Optional["Users"] = Relationship(
back_populates="feedback",
sa_relationship_kwargs={"lazy": "selectin"},
)
inbox: Optional["Inbox"] = Relationship(
back_populates="feedback",
sa_relationship_kwargs={"lazy": "selectin"},
)
@staticmethod
def _as_uuid(record_id) -> uuid.UUID | None:
try:
return uuid.UUID(str(record_id))
except ValueError:
return None
@classmethod
async def get_feedback_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()
@classmethod
async def get_feedback_by_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(
select(cls).where(cls.inbox_id == inbox_id).order_by(cls.created_at.desc())
)
return result.scalars().all()
@classmethod
async def insert_feedback(cls, session: AsyncSession, fields: dict):
row = cls(**fields)
session.add(row)
await session.commit()
return await cls.get_feedback_by_id(session, row.id)
@classmethod
async def update_feedback(cls, session: AsyncSession, record_id, fields: dict):
row = await cls.get_feedback_by_id(session, record_id)
if not row:
return None
for key, value in fields.items():
setattr(row, key, value)
row.updated_at = _now()
session.add(row)
await session.commit()
await session.refresh(row)
return row
import users.models as _users_models # noqa: E402, F401

View File

@ -28,3 +28,46 @@ def normalize_spaced_text(text) -> str:
lines = [re.sub(r" {2,}", " ", line).strip() for line in text.splitlines()]
return re.sub(r"\n{3,}", "\n\n", "\n".join(lines)).strip()
# Mirrors frontend Inbox.jsx sourceFrom — board name is tagged in the To address.
INBOX_SOURCES = (
"Microsoft Outlook",
"Career Portal",
"Manual CV Upload",
"LinkedIn",
"Indeed",
"Rozee",
"Mustakbil",
"Employee Referral",
"Recruitment Agency",
"Campus Hiring",
"Walk-in",
)
def _letters_only(value: str) -> str:
return re.sub(r"[^a-z]", "", (value or "").lower())
def source_from_message_to(message_to: str | None) -> str:
raw = (message_to or "").strip()
if not raw:
return "Unknown"
flat = _letters_only(raw)
for name in INBOX_SOURCES:
if _letters_only(name) and _letters_only(name) in flat:
return name
return raw.split(",")[0].strip()
def documents_from_message(file_name: str | None, file_path: str | None) -> list[dict]:
names = [n.strip() for n in (file_name or "").split(",") if n.strip()]
paths = [p.strip() for p in (file_path or "").split(",") if p.strip()]
out = []
for i, name in enumerate(names):
out.append({"name": name, "path": paths[i] if i < len(paths) else None})
if not out and paths:
for path in paths:
out.append({"name": path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1], "path": path})
return out

View File

@ -1,16 +1,25 @@
from inbox.models import Inbox
from typing import Any,List,Dict
from job.candidate.plugins import documents_from_message, source_from_message_to
from job.interviews.serializers import serialize_interview
from job.activity.serializers import serialize_activity
from job.feedback.serializers import serialize_feedback
def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]]) -> Dict[str,Any]|List[Dict[str,Any]]:
def serialize_candidate_profile(
link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[str,Any]],
*,
detail:bool=False,
) -> Dict[str,Any]|List[Dict[str,Any]]:
if isinstance(link,list):
return [serialize_candidate_profile(item) for item in link]
return [serialize_candidate_profile(item,detail=detail) for item in link]
if isinstance(link,dict):
return link
user = link.user
message = link.messages
return {
payload = {
"inbox_id": link.id,
"user_id": str(link.user_id) if link.user_id else None,
"name": user.name if user else None,
@ -20,8 +29,10 @@ def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[s
"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,
"current_employment": message.current_employment 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 [],
"assigned_job_post_id": str(message.assigned_job_post_id) if message and message.assigned_job_post_id else None,
"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,
@ -29,3 +40,31 @@ def serialize_candidate_profile(link:Inbox|List[Inbox]|Dict[str,Any]|List[Dict[s
"matched_at": message.matched_at.isoformat() if message and message.matched_at else None,
"job_posts": [],
}
if not detail:
return payload
payload.update({
"favorite": link.favorite,
"rating": link.rating,
"phone": message.candidate_phone_number if message else None,
"education": message.candidate_education if message else None,
"currentCompany": message.current_employment if message else None,
"stage": message.application_status if message else None,
"source": source_from_message_to(message.message_to if message else None),
"applied": message.message_received_time if message else None,
"documents": documents_from_message(
message.file_name if message else None,
message.file_path if message else None,
),
"recruiter": None,
"recruiter_id": None,
"job_title": None,
"ai_score": None,
"recommendation": None,
"sub_scores": None,
"interviews": [serialize_interview(r) for r in (link.interviews or [])],
"activity": [serialize_activity(r) for r in (link.activity or [])],
"feedback": [serialize_feedback(r) for r in (link.feedback or [])],
"notes": [],
})
return payload

View File

@ -4,10 +4,13 @@ from datetime import datetime,timezone
from fastapi import HTTPException
from pypdf import PdfReader
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from sqlmodel import true
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.candidate.serializers import serialize_candidate_profile
from job.candidate.models import Notes
from job.notes.serializers import serialize_note
from inbox.models import Inbox_Messages,Inbox
from job.candidate.plugins import normalize_spaced_text
@ -77,7 +80,12 @@ class CandidateView:
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)
detail=bool(user_id)
# Detail mode must see every application for the candidate, not one page.
fetch_limit=1000 if detail else limit
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
if detail:
return await self.attach_profile_detail(rows)
return await self.attach_job_posts(rows)
except HTTPException:
raise
@ -92,7 +100,27 @@ class CandidateView:
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
async def get_job_post_by_id(self,record_id,data=None):
async def update_candidate(self,user_id,payload):
try:
if not user_id:
raise HTTPException(status_code=400,detail="user_id is required")
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
if not fields:
raise HTTPException(status_code=400,detail="favorite or rating is required")
links=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
records=links if isinstance(links,list) else ([links] if links else [])
if not records:
raise HTTPException(status_code=404,detail="Candidate not found")
for link in records:
await Inbox.update_inbox(self.session,link.id,fields)
refreshed=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=100,offset=0)
return await self.attach_profile_detail(refreshed)
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,*,as_assigned=False):
"""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)
@ -100,7 +128,20 @@ class CandidateView:
return None
payload=serialize_job_post(job_post_data)
if isinstance(data,dict):
data.setdefault("job_posts",[]).append(payload)
if as_assigned:
data["assigned_job_post"]=payload
if payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if payload.get("title"):
data["job_title"]=payload.get("title")
else:
data.setdefault("job_posts",[]).append(payload)
if data.get("recruiter") is None and payload.get("created_by_name"):
data["recruiter"]=payload.get("created_by_name")
data["recruiter_id"]=payload.get("created_by")
if data.get("job_title") is None and payload.get("title"):
data["job_title"]=payload.get("title")
return payload
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@ -113,7 +154,92 @@ class CandidateView:
for record in records:
payload=serialize_candidate_profile(record)
payload["job_posts"]=[]
payload["assigned_job_post"]=None
assigned_id=payload.get("assigned_job_post_id")
if assigned_id:
await self.get_job_post_by_id(record_id=assigned_id,data=payload,as_assigned=True)
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
async def attach_profile_detail(self,data):
"""Detail mode: flatten child collections across every Inbox row for the candidate."""
single=not isinstance(data,list)
records=[data] if single else list(data or [])
if not records:
return {} if single else []
interviews=[]
activity=[]
feedback=[]
documents=[]
job_posts=[]
assigned_job_post=None
base=None
user_id=None
favorite=None
rating=None
for record in records:
payload=serialize_candidate_profile(record,detail=True)
if base is None:
base=payload
user_id=payload.get("user_id")
favorite=payload.get("favorite")
rating=payload.get("rating")
interviews.extend(payload.get("interviews") or [])
activity.extend(payload.get("activity") or [])
feedback.extend(payload.get("feedback") or [])
documents.extend(payload.get("documents") or [])
if payload.get("assigned_job_post_id") and assigned_job_post is None:
await self.get_job_post_by_id(
record_id=payload.get("assigned_job_post_id"),
data=payload,
as_assigned=True,
)
assigned_job_post=payload.get("assigned_job_post")
if base.get("recruiter") is None and payload.get("recruiter"):
base["recruiter"]=payload.get("recruiter")
base["recruiter_id"]=payload.get("recruiter_id")
if base.get("job_title") is None and payload.get("job_title"):
base["job_title"]=payload.get("job_title")
for job_id in payload.get("suggested_job_post_ids") or []:
await self.get_job_post_by_id(record_id=job_id,data=payload)
for jp in payload.get("job_posts") or []:
if not any(x.get("id")==jp.get("id") for x in job_posts):
job_posts.append(jp)
if base.get("recruiter") is None and payload.get("recruiter"):
base["recruiter"]=payload.get("recruiter")
base["recruiter_id"]=payload.get("recruiter_id")
if base.get("job_title") is None and payload.get("job_title"):
base["job_title"]=payload.get("job_title")
notes=[]
uid=Notes._as_uuid(user_id) if user_id else None
if uid is not None:
result=await self.session.execute(
select(Notes)
.options(selectinload(Notes.author))
.where(Notes.user_id==uid)
.order_by(Notes.created_at.desc())
)
notes=[serialize_note(r) for r in result.scalars().all()]
activity.sort(key=lambda r:(r.get("activity_date") or ""),reverse=True)
base["favorite"]=favorite
base["rating"]=rating
base["interviews"]=interviews
base["activity"]=activity
base["feedback"]=feedback
base["documents"]=documents
base["notes"]=notes
base["job_posts"]=job_posts or base.get("job_posts") or []
base["assigned_job_post"]=assigned_job_post
if assigned_job_post:
base["assigned_job_post_id"]=assigned_job_post.get("id")
if assigned_job_post.get("created_by_name"):
base["recruiter"]=assigned_job_post.get("created_by_name")
base["recruiter_id"]=assigned_job_post.get("created_by")
if assigned_job_post.get("title"):
base["job_title"]=assigned_job_post.get("title")
return base

View File

@ -0,0 +1,14 @@
def serialize_feedback(row) -> dict:
reviewer=getattr(row,"user",None)
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"review": row.review,
"financial_status": row.financial_status,
"score": row.score,
"note": row.note,
"reviewed_by": str(row.reviewed_by) if row.reviewed_by else None,
"reviewed_by_name": reviewer.name if reviewer else 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,
}

View File

@ -0,0 +1,63 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import select
from job.candidate.models import Feedback
from job.feedback.serializers import serialize_feedback
class FeedbackView:
def __init__(self,session:AsyncSession):
self.session=session
async def _load(self,record_id):
uid=Feedback._as_uuid(record_id)
if uid is None:
return None
result=await self.session.execute(
select(Feedback).options(selectinload(Feedback.user)).where(Feedback.id==uid)
)
return result.scalars().first()
async def get_feedback(self,feedback_id=None,inbox_id=None):
if feedback_id:
row=await self._load(feedback_id)
if not row:
raise HTTPException(status_code=404,detail="Feedback not found")
return serialize_feedback(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="feedback_id or inbox_id is required")
result=await self.session.execute(
select(Feedback)
.options(selectinload(Feedback.user))
.where(Feedback.inbox_id==int(inbox_id))
.order_by(Feedback.created_at.desc())
)
return [serialize_feedback(r) for r in result.scalars().all()]
async def create_feedback(self,payload,current_user):
fields={
"review":payload.get("review") or "",
"financial_status":payload.get("financial_status") or "",
"score":payload.get("score") if payload.get("score") is not None else 0.0,
"note":payload.get("note"),
"inbox_id":payload.get("inbox_id"),
"reviewed_by":payload.get("reviewed_by") or (
current_user.get("id") if isinstance(current_user,dict) else None
),
}
row=await Feedback.insert_feedback(self.session,fields)
row=await self._load(row.id)
return serialize_feedback(row)
async def update_feedback(self,feedback_id,payload):
allowed=("review","financial_status","score","note","inbox_id","reviewed_by")
fields={k:v for k,v in payload.items() if v is not None and k in allowed}
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
row=await Feedback.update_feedback(self.session,feedback_id,fields)
if not row:
raise HTTPException(status_code=404,detail="Feedback not found")
row=await self._load(row.id)
return serialize_feedback(row)

View File

@ -0,0 +1,9 @@
def serialize_interview(row) -> dict:
return {
"id": str(row.id),
"inbox_id": row.inbox_id,
"interview_date": row.interview_date.isoformat() if row.interview_date else None,
"interview_time": row.interview_time.isoformat() if row.interview_time else None,
"interview_type": row.interview_type,
"interview_status": row.interview_status,
}

View File

@ -0,0 +1,42 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from job.candidate.models import Interviews
from job.interviews.serializers import serialize_interview
class Interview:
def __init__(self,session:AsyncSession):
self.session=session
async def get_interview(self,interview_id=None,inbox_id=None):
if interview_id:
row=await Interviews.get_interview_by_id(self.session,interview_id)
if not row:
raise HTTPException(status_code=404,detail="Interview not found")
return serialize_interview(row)
if inbox_id is None:
raise HTTPException(status_code=400,detail="interview_id or inbox_id is required")
rows=await Interviews.get_interviews_by_inbox(self.session,int(inbox_id))
return [serialize_interview(r) for r in rows]
async def create_interview(self,payload):
fields={
"interview_date":payload.get("interview_date"),
"interview_time":payload.get("interview_time"),
"interview_type":payload.get("interview_type") or "",
"interview_status":payload.get("interview_status") or "",
"inbox_id":payload.get("inbox_id"),
}
fields={k:v for k,v in fields.items() if v is not None}
row=await Interviews.insert_interview(self.session,fields)
return serialize_interview(row)
async def update_interview(self,interview_id,payload):
fields={k:v for k,v in payload.items() if v is not None}
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
row=await Interviews.update_interview(self.session,interview_id,fields)
if not row:
raise HTTPException(status_code=404,detail="Interview not found")
return serialize_interview(row)

View File

@ -2,7 +2,7 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON
from sqlalchemy import DateTime, JSON, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, Relationship, SQLModel, select
@ -69,6 +69,57 @@ class JobPosts(SQLModel, table=True):
)
return result.scalars().all()
@classmethod
async def get_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = True):
uids = []
for raw in ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
uids.append(uid)
if not uids:
return []
statement = select(cls).where(cls.id.in_(uids))
if active_only:
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
result = await session.execute(statement)
rows = list(result.scalars().all())
by_id = {str(r.id): r for r in rows}
# Preserve request order so suggestion ranks stay stable.
return [by_id[str(u)] for u in uids if str(u) in by_id]
@classmethod
async def fetch_job_posts(
cls,
session: AsyncSession,
*,
search: str | None = None,
top: int | None = None,
skip: int = 0,
ids: list[str] | None = None,
active_only: bool = True,
):
if ids:
rows = await cls.get_by_ids(session, ids, active_only=active_only)
return rows, len(rows)
statement = select(cls)
if active_only:
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
if search:
like = f"%{search.strip()}%"
statement = statement.where(
or_(cls.title.ilike(like), cls.location.ilike(like))
)
count_statement = select(func.count()).select_from(statement.subquery())
total = (await session.execute(count_statement)).scalar_one()
statement = statement.order_by(cls.created_at.desc())
if skip:
statement = statement.offset(skip)
if top is not None:
statement = statement.limit(top)
result = await session.execute(statement)
return list(result.scalars().all()), total
@classmethod
async def insert_job_post(cls, session: AsyncSession, fields: dict):
row = cls(**fields)

View File

@ -21,6 +21,7 @@ def serialize_job_post(row) -> dict:
"status": row.status,
"buffer_error": row.buffer_error,
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": row.user.name if getattr(row, "user", None) else 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,
}

View File

@ -132,3 +132,14 @@ class JobPost:
return await list_buffer_channels()
except (httpx.HTTPError,BufferError,RuntimeError) as e:
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
rows,total=await JobPosts.fetch_job_posts(
self.session,
search=search,
top=top,
skip=skip,
ids=ids,
active_only=active_only,
)
return [serialize_job_post(r) for r in rows],total

View File

@ -0,0 +1,11 @@
def serialize_note(row) -> dict:
author=getattr(row,"author",None)
return {
"id": str(row.id),
"note": row.note,
"user_id": str(row.user_id) if row.user_id else None,
"created_by": str(row.created_by) if row.created_by else None,
"created_by_name": author.name if author else 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,
}

View File

@ -0,0 +1,62 @@
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import select
from job.candidate.models import Notes
from job.notes.serializers import serialize_note
class Note:
def __init__(self,session:AsyncSession):
self.session=session
async def _load(self,record_id):
uid=Notes._as_uuid(record_id)
if uid is None:
return None
result=await self.session.execute(
select(Notes).options(selectinload(Notes.author)).where(Notes.id==uid)
)
return result.scalars().first()
async def get_note(self,note_id=None,user_id=None):
if note_id:
row=await self._load(note_id)
if not row:
raise HTTPException(status_code=404,detail="Note not found")
return serialize_note(row)
if not user_id:
raise HTTPException(status_code=400,detail="note_id or user_id is required")
uid=Notes._as_uuid(user_id)
if uid is None:
raise HTTPException(status_code=400,detail="Invalid user_id")
result=await self.session.execute(
select(Notes)
.options(selectinload(Notes.author))
.where(Notes.user_id==uid)
.order_by(Notes.created_at.desc())
)
return [serialize_note(r) for r in result.scalars().all()]
async def create_note(self,payload,current_user):
fields={
"note":payload.get("note") or "",
"user_id":payload.get("user_id"),
"created_by":current_user.get("id") if isinstance(current_user,dict) else None,
}
if not fields["user_id"]:
raise HTTPException(status_code=400,detail="user_id is required")
row=await Notes.insert_note(self.session,fields)
row=await self._load(row.id)
return serialize_note(row)
async def update_note(self,note_id,payload):
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
if not fields:
raise HTTPException(status_code=400,detail="No fields to update")
row=await Notes.update_note(self.session,note_id,fields)
if not row:
raise HTTPException(status_code=404,detail="Note not found")
row=await self._load(row.id)
return serialize_note(row)

View File

@ -1,6 +1,6 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING,List,Optional
from sqlalchemy import func, or_
from sqlalchemy.ext.asyncio import AsyncSession
@ -12,6 +12,7 @@ from job.job_post.models import JobPosts
if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module
from inbox.models import Inbox
from job.candidate.models import Feedback, Notes
class Users(SQLModel, table=True):
__tablename__ = "users"
@ -26,15 +27,29 @@ class Users(SQLModel, table=True):
# 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.
job_posts: list[JobPosts] = Relationship(
job_posts: List[JobPosts] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
)
inbox: list["Inbox"] = Relationship(
inbox: List["Inbox"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
)
feedback: List["Feedback"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin"},
)
notes: List["Notes"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.user_id]"},
)
authored_notes: List["Notes"] = Relationship(
back_populates="author",
sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"},
)
password: str
created_at: datetime = Field(default_factory=datetime.now)
updated_at: datetime = Field(default_factory=datetime.now)
is_active: bool = Field(default=False)
@ -134,3 +149,6 @@ class Users(SQLModel, table=True):
await session.commit()
await session.refresh(user)
return user
import job.candidate.models as _candidate_models # noqa: E402, F401

View File

@ -17,6 +17,7 @@ import ConfirmEmail from './pages/ConfirmEmail'
const SCREENS = {
dashboard: lazy(() => import('./screens/Dashboard')),
inbox: lazy(() => import('./screens/Inbox')),
matching: lazy(() => import('./screens/Matching')),
jobs: lazy(() => import('./screens/Jobs')),
candidates: lazy(() => import('./screens/Candidates')),
talentpool: lazy(() => import('./screens/TalentPool')),

View File

@ -25,6 +25,7 @@ import ConfirmEmail from '../pages/ConfirmEmail'
import Dashboard from '../screens/Dashboard'
import Inbox from '../screens/Inbox'
import Matching from '../screens/Matching'
import Jobs from '../screens/Jobs'
import Candidates from '../screens/Candidates'
import TalentPool from '../screens/TalentPool'
@ -48,7 +49,7 @@ import Settings from '../screens/Settings'
import Help from '../screens/Help'
const SCREENS = {
dashboard: Dashboard, inbox: Inbox, jobs: Jobs, candidates: Candidates,
dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates,
talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard,
recruiterhub: RecruiterHub, tasks: Tasks, aiassistant: AiAssistant,
interviews: Interviews, assessments: Assessments, offers: Offers,

View File

@ -17,6 +17,13 @@ export function list({ search, limit, offset } = {}) {
/**
* One candidate by users.id.
*
* Passing user_id switches the endpoint into DETAIL mode
* (backend/job/candidate/views.py:get_candidate), which is a different and much
* larger payload than the list rows: résumé text, the AI match verdict, phone,
* education, source, documents, favorite/rating, and the four child collections
* interviews, activity, feedback, notes flattened across every inbox row the
* candidate owns.
*
* 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().
@ -30,3 +37,58 @@ export function toRows(res) {
if (Array.isArray(res?.data)) return res.data
return res?.data ? [res.data] : []
}
/**
* favorite/rating live on the `inbox` row, not on the user, so the server applies
* the change to EVERY application belonging to the candidate and hands back the
* refreshed detail payload. Pipeline stage is not writable here no endpoint
* updates inbox_messages.application_status yet.
*/
export function update(userId, payload) {
return request('/candidate/update', { method: 'PATCH', params: { user_id: userId }, body: payload })
}
/* ------------------------------------------------------------------
Child records of a profile.
Reads are deliberately absent: the detail payload above already bundles all
four collections, so a separate GET per tab would be a second round trip for
data the modal is holding. Writers invalidate qk.candidates.detail(userId) and
the whole modal repaints from one refetch.
Scoping differs by table and is not interchangeable notes hang off the
candidate (users.id), while interviews, activity and feedback hang off one
application (inbox.id).
------------------------------------------------------------------ */
export function createNote({ userId, note }) {
return request('/notes/create', { method: 'POST', body: { user_id: userId, note } })
}
export function createInterview({ inboxId, date, time, type, status }) {
return request('/interview/create', {
method: 'POST',
body: {
inbox_id: inboxId,
interview_date: date,
interview_time: time,
interview_type: type,
interview_status: status,
},
})
}
/** `reviewed_by` is omitted on purpose: the server stamps the caller. */
export function createFeedback({ inboxId, review, score, note }) {
return request('/feedback/create', {
method: 'POST',
body: { inbox_id: inboxId, review, score, note },
})
}
export function createActivity({ inboxId, type, status, description }) {
return request('/activity/create', {
method: 'POST',
body: { inbox_id: inboxId, activity_type: type, activity_status: status, description },
})
}

View File

@ -16,15 +16,26 @@ export function listMessages() {
*
* Unlike /inbox/fetch this one IS permissioned server-side
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
*
* `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue.
*/
export function listApplications({ search, top, skip, recordId, isread, applicationStatus } = {}) {
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned } = {}) {
return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops
// undefined but keeps false, so `isread: undefined` sends no param at all.
// Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
params: { search, top, skip, record_id: recordId, isread, application_status: applicationStatus },
params: {
search,
top,
skip,
record_id: recordId,
isread,
application_status: applicationStatus,
assigned,
},
})
}
@ -49,3 +60,16 @@ export function syncMailbox({ token, top, skip } = {}) {
export function markRead(recordId) {
return request(`/inbox/${recordId}/read`, { method: 'POST' })
}
/** Assign (or clear with null) the job post for one application. Requires inbox.edit. */
export function assignJobPost(recordId, jobPostId) {
return request(`/inbox/${recordId}/assign-job-post`, {
method: 'PATCH',
body: { job_post_id: jobPostId },
})
}
/** Re-queue the matching agent for one application. Requires inbox.edit. */
export function rematch(recordId) {
return request(`/inbox/${recordId}/match`, { method: 'POST' })
}

View File

@ -0,0 +1,20 @@
import { request } from '../lib/apiClient'
/**
* Active job posts Job Matching hydrates suggestions and the manual picker.
*
* Permissioned with job_board.view (not jobs.*). `ids` is a comma-joined list
* so one round trip can resolve a whole suggestion rail.
*/
export function list({ search, top, skip, ids, activeOnly = true } = {}) {
const idParam = Array.isArray(ids) ? ids.filter(Boolean).join(',') : ids
return request('/job/fetch', {
params: {
search,
top,
skip,
ids: idParam || undefined,
active_only: activeOnly,
},
})
}

View File

@ -2,7 +2,7 @@ import { NavLink } from 'react-router-dom'
import { NAV_GROUPS, ROUTES } from './routes'
import { useAuth } from '../auth/AuthContext'
import Icon from '../ui/icons'
import BrandMark from '../components/BrandMark'
import { BrandGlyph } from '../components/BrandMark'
export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) {
const { can } = useAuth()
@ -18,7 +18,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
>
<div className="sidebar-brand">
<div className="brand-logo">
<BrandMark />
<BrandGlyph />
</div>
<div className="brand-text">
<span className="brand-name">TalentFlow</span>

View File

@ -18,6 +18,7 @@ export const ROUTES = [
// --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'inbox.view', badge: 'matching' },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
{ path: 'talentpool', title: 'Talent Pool', icon: 'talent', group: 'Workspace', permission: 'candidates.view' },

View File

@ -3,6 +3,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { seedQuery } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import * as inboxApi from '../api/inbox'
const SIDEBAR_KEY = 'tf-sidebar'
@ -76,20 +78,31 @@ export function useHotkeys({ onEscape }) {
}
/**
* The four sidebar badge counts. App.updateBadges() was an imperative DOM write
* The sidebar badge counts. App.updateBadges() was an imperative DOM write
* that every mutating call site had to remember to call; these are derived, so
* completing a task updates the badge with no call site involved at all.
*
* `matching` is the unassigned applications queue the one number Job Matching
* exists to drive to zero.
*/
export function useBadges() {
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: notifications = [] } = useQuery(seedQuery('notifications'))
const { data: tasks = [] } = useQuery(seedQuery('tasks'))
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
const { data: matchingTotal = 0 } = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false }),
queryFn: async () => {
const res = await inboxApi.listApplications({ assigned: false, top: 1 })
return res?.total ?? 0
},
})
return {
jobs: jobs.filter((j) => j.status === 'Open').length,
notifications: notifications.filter((n) => n.unread).length,
tasks: tasks.filter((t) => !t.done).length,
inbox: inbox.filter((i) => i.unread).length,
matching: matchingTotal,
}
}

View File

@ -1,11 +1,25 @@
/* The wordmark, in two pieces.
BrandGlyph is the emblem on its own. The sidebar paints its own `.brand-logo`
tile and its own `.brand-name`, so handing it the full BrandMark nested one
green tile inside another and printed "TalentFlow" twice once inside the
tile, once beside it. Callers that already supply their own chrome take the
glyph; callers that want the whole lockup (the auth screens) take the default. */
export function BrandGlyph() {
return (
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
<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" />
</svg>
)
}
export default function BrandMark({ size = 'md', showName = true }) {
const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo'
return (
<div className="auth-brand">
<div className={logoClass}>
<svg className="brand-mark" viewBox="0 0 100 64.46" aria-hidden="true">
<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" />
</svg>
<BrandGlyph />
</div>
{showName ? <span className="brand-name">TalentFlow</span> : null}
</div>

View File

@ -26,6 +26,11 @@ export const qk = {
messages: () => ['mailbox', 'messages'],
applications: (p = {}) => ['mailbox', 'applications', p],
message: (id) => ['mailbox', 'message', id],
assignments: (p = {}) => ['mailbox', 'assignments', p],
},
jobPosts: {
all: () => ['jobPosts'],
list: (p = {}) => ['jobPosts', 'list', p],
},
// --- seed-backed buckets ---

View File

@ -1,18 +1,106 @@
/* The 8-tab candidate profile modal, split out of Candidates.jsx it was the
single largest block in js/candidates.js and deserves its own file. */
single largest block in js/candidates.js and deserves its own file.
TWO DATA MODES, selected by whether the caller passes a `userId`:
SEED (Candidates.jsx) every tab renders from the seed record, exactly as
the prototype did.
LIVE (TalentPool.jsx) GET /candidate/fetch?user_id= switches the endpoint
into detail mode and returns the real record: résumé text, the agent's
match verdict, documents, and the four child collections (interviews,
notes, activity, feedback). The write tabs POST to their own endpoints
and invalidate this one query, so the whole modal repaints from a single
refetch.
Live collections are NEVER padded with the seed's demo rows. An empty tab gets
an empty state, because inventing three scorecards for a real applicant is
worse than showing none.
Scoping differs between the child tables and is not interchangeable: notes
hang off the candidate (users.id), while interviews, activity and feedback
hang off one application (inbox.id). */
import { useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { seedQuery } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import { companies, fmtDate, moneyK, pick } from '../data/seed'
const TABS = ['Overview', 'Resume', 'Timeline', 'Interview', 'Notes', 'Activity', 'Documents', 'Feedback']
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
const REVIEWS = ['Strong Hire', 'Hire', 'Lean Hire', 'No Hire']
const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture Fit', 'Final Round']
const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show']
const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note']
/** Seed timestamps are Date objects; the API sends ISO strings. */
function fmtWhen(value, fallback = '—') {
if (!value) return fallback
const d = value instanceof Date ? value : new Date(value)
return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d)
}
function fmtClock(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
}
function stamp(value) {
const d = value instanceof Date ? value : new Date(value ?? NaN)
return Number.isNaN(d.getTime()) ? 0 : d.getTime()
}
/** <input type="date"> + <input type="time"> -> one ISO instant, or null. */
function toInstant(date, time) {
if (!date) return null
const d = new Date(`${date}T${time || '00:00'}`)
return Number.isNaN(d.getTime()) ? null : d.toISOString()
}
function Info({ label, val }) {
return (
<div className="info-item">
<div className="il">{label}</div>
<div className="iv">{val === 0 || val ? val : '—'}</div>
</div>
)
}
function useCandidateDetail(userId) {
return useQuery({
queryKey: qk.candidates.detail(userId),
queryFn: async () => candidatesApi.toRows(await candidatesApi.getByUserId(userId))[0] ?? null,
enabled: Boolean(userId),
})
}
/**
* A write against one of the child endpoints. Every one of them invalidates the
* single detail query the modal renders from, so a saved note and a submitted
* scorecard both land through the same refetch rather than through hand-patched
* cache entries that could drift from the server's view.
*/
function useProfileWrite({ userId, mutationFn, success, onDone }) {
const qc = useQueryClient()
const { toast } = useToast()
return useMutation({
mutationFn,
onSuccess: async (_data, vars) => {
await qc.invalidateQueries({ queryKey: qk.candidates.detail(userId) })
toast(typeof success === 'function' ? success(vars) : success, 'success')
onDone?.()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not save. Please try again.'), 'error'),
})
}
export default function CandidateProfile({ candidate: c, onClose, onAdvance, onToggleFav, onAtsMatch }) {
const { toast } = useToast()
@ -20,12 +108,53 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
const { data: interviews = [] } = useQuery(seedQuery('interviews'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const isLive = Boolean(c.userId)
const detail = useCandidateDetail(c.userId)
const live = detail.data ?? null
// The prototype called DB.pick() inline while rendering, so the "previous
// employer" changed every repaint. Fixed per candidate.
const priorCompany = useMemo(() => pick(companies), [])
const candidateInterviews = interviews.filter((i) => i.candidateId === c.id)
// The application row interviews/activity/feedback attach to. Detail mode
// flattens every application the candidate owns; writes land on the first,
// which is the one the header is describing.
const inboxId = live?.inbox_id ?? null
const favorite = live ? Boolean(live.favorite) : c.favorite
const setFavorite = useProfileWrite({
userId: c.userId,
mutationFn: (next) => candidatesApi.update(c.userId, { favorite: next }),
success: (next) => (next ? `${c.name} added to favorites` : 'Removed from favorites'),
})
const title = live?.job_title || c.currentTitle
const company = live?.currentCompany || c.currentCompany
const counts = live && {
Interview: live.interviews?.length ?? 0,
Notes: live.notes?.length ?? 0,
Activity: live.activity?.length ?? 0,
Documents: live.documents?.length ?? 0,
Feedback: live.feedback?.length ?? 0,
}
// In live mode nothing below the hero can be trusted until the detail payload
// lands, so one guard replaces every tab body rather than each tab inventing
// its own half-loaded state.
const guard = !isLive ? null
: detail.isPending ? (
<EmptyState icon="refresh" title="Loading candidate…">Fetching the full record.</EmptyState>
) : detail.isError ? (
<EmptyState icon="alert" title="Could not load this candidate">
{friendlyAuthError(detail.error, 'Please try again.')}
</EmptyState>
) : !live ? (
<EmptyState icon="user" title="No record found">This candidate is no longer in the pipeline.</EmptyState>
) : null
return (
<Modal
title="Candidate Profile"
@ -35,11 +164,12 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
footer={
<>
<button
className={`btn btn-ghost star-btn${c.favorite ? ' on' : ''}`}
className={`btn btn-ghost star-btn${favorite ? ' on' : ''}`}
style={{ marginRight: 'auto' }}
onClick={() => onToggleFav(c)}
disabled={isLive && (setFavorite.isPending || !live)}
onClick={() => (isLive ? setFavorite.mutate(!favorite) : onToggleFav(c))}
>
<Icon name="star" /> {c.favorite ? 'Favorited' : 'Favorite'}
<Icon name="star" /> {favorite ? 'Favorited' : 'Favorite'}
</button>
<button className="btn btn-secondary" onClick={() => onAtsMatch(c)}>
<Icon name="target" /> ATS Match
@ -56,10 +186,10 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
<div className="profile-hero">
<Avatar name={c.name} initials={c.initials} color={c.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name">{c.name}</div>
<div className="ph-role">{c.currentTitle} at {c.currentCompany}</div>
<div className="ph-name">{live?.name || c.name}</div>
<div className="ph-role">{company ? `${title} at ${company}` : title}</div>
<div className="ph-tags">
<Badge>{c.stage}</Badge> <Badge className="b-gray">{c.source}</Badge>
<Badge>{c.stage}</Badge> <Badge className="b-gray">{live?.source || c.source}</Badge>
<span className="badge b-plain b-indigo badge-plain">{c.experience} yrs exp</span>
</div>
</div>
@ -70,32 +200,87 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
<div style={{ marginTop: 22 }}>
<Tabs value={tab} onChange={setTab} tabs={TABS.map((t) => ({ key: t, label: t }))} />
<Tabs
value={tab}
onChange={setTab}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts ? counts[t] : undefined }))}
/>
</div>
<div className="tab-pane active">
{tab === 'Overview' && (
{tab === 'Overview' && (guard || (live ? (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{c.email}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{c.phone}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{c.location}</div></div>
<div className="info-item"><div className="il">Applied For</div><div className="iv">{c.jobTitle}</div></div>
<div className="info-item"><div className="il">Current Company</div><div className="iv">{c.currentCompany}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{c.experience} years</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{c.education}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{c.source}</div></div>
<div className="info-item"><div className="il">Recruiter</div><div className="iv">{c.recruiter}</div></div>
<div className="info-item"><div className="il">Applied On</div><div className="iv">{fmtDate(c.applied)}</div></div>
<div className="info-item"><div className="il">Expected Salary</div><div className="iv">{moneyK(c.salary)}</div></div>
<div className="info-item"><div className="il">Rating</div><div className="iv"> {c.rating} / 5.0</div></div>
<Info label="Email" val={live.email} />
<Info label="Phone" val={live.phone} />
<Info label="Applied For" val={live.job_title} />
<Info label="Current Company" val={live.currentCompany} />
<Info label="Experience" val={live.experience} />
<Info label="Education" val={live.education} />
<Info label="Source" val={live.source} />
<Info label="Recruiter" val={live.recruiter} />
<Info label="Applied On" val={fmtWhen(live.applied)} />
<Info label="Screened On" val={fmtWhen(live.matched_at)} />
<Info label="Rating" val={`${(live.rating ?? 0).toFixed(1)} / 5.0`} />
<Info label="Applications" val={live.job_posts?.length || 0} />
</div>
{(live.match_summary || live.match_reasoning) && (
<>
<div style={LABEL}>AI Screening</div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 18 }}>
<div className="card-body">
{live.match_summary && <p style={{ marginBottom: live.match_reasoning ? 10 : 0 }}>{live.match_summary}</p>}
{live.match_reasoning && <p className="text-muted text-sm">{live.match_reasoning}</p>}
</div>
</div>
</>
)}
{live.assigned_job_post && (
<>
<div style={LABEL}>Assigned Role</div>
<div className="k-tags" style={{ marginBottom: 14 }}>
<span className="tag" style={{ background: 'var(--primary-soft)', color: 'var(--primary-fg)' }}>
{live.assigned_job_post.title}
</span>
</div>
</>
)}
{live.job_posts?.length > 0 && (
<>
<div style={LABEL}>Suggested Roles</div>
<div className="k-tags">
{live.job_posts.map((j) => <span className="tag" key={j.id}>{j.title}</span>)}
</div>
</>
)}
</>
) : (
<>
<div className="info-grid" style={{ marginBottom: 20 }}>
<Info label="Email" val={c.email} />
<Info label="Phone" val={c.phone} />
<Info label="Location" val={c.location} />
<Info label="Applied For" val={c.jobTitle} />
<Info label="Current Company" val={c.currentCompany} />
<Info label="Experience" val={`${c.experience} years`} />
<Info label="Education" val={c.education} />
<Info label="Source" val={c.source} />
<Info label="Recruiter" val={c.recruiter} />
<Info label="Applied On" val={fmtWhen(c.applied)} />
<Info label="Expected Salary" val={moneyK(c.salary)} />
<Info label="Rating" val={`${c.rating} / 5.0`} />
</div>
<div style={LABEL}>Skills</div>
<div className="k-tags">{c.skills.map((s) => <span className="tag" key={s}>{s}</span>)}</div>
</>
)}
)))}
{tab === 'Resume' && (
{tab === 'Resume' && (guard || (live ? (
<ResumeTab live={live} />
) : (
<>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
@ -125,9 +310,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
<Icon name="download" /> Download PDF
</button>
</>
)}
)))}
{tab === 'Timeline' && (
{tab === 'Timeline' && (guard || (live ? (
<TimelineTab live={live} />
) : (
<div className="timeline">
{[
{ icon: 'user-plus', title: 'Application received', meta: fmtDate(c.applied), desc: `Applied via ${c.source}` },
@ -144,9 +331,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
))}
</div>
)}
)))}
{tab === 'Interview' && (
{tab === 'Interview' && (guard || (live ? (
<InterviewTab userId={c.userId} inboxId={inboxId} rows={live.interviews ?? []} />
) : (
candidateInterviews.length ? (
<div className="list-tight">
{candidateInterviews.map((iv) => (
@ -167,9 +356,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
Schedule an interview to get started.
</EmptyState>
)
)}
)))}
{tab === 'Notes' && (
{tab === 'Notes' && (guard || (live ? (
<NotesTab userId={c.userId} rows={live.notes ?? []} />
) : (
<>
<div className="form-field">
<label>Add a note</label>
@ -201,9 +392,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
</div>
</>
)}
)))}
{tab === 'Activity' && (
{tab === 'Activity' && (guard || (live ? (
<ActivityTab userId={c.userId} inboxId={inboxId} rows={live.activity ?? []} />
) : (
<div className="list-tight">
{[
{ icon: 'eye', tone: 'i-green', text: `Profile viewed by ${c.recruiter}`, when: '1h ago' },
@ -222,9 +415,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
))}
</div>
)}
)))}
{tab === 'Documents' && (
{tab === 'Documents' && (guard || (live ? (
<DocumentsTab rows={live.documents ?? []} />
) : (
<div className="list-tight">
{[
{ n: 'Resume.pdf', s: '284 KB' }, { n: 'Cover_Letter.pdf', s: '112 KB' },
@ -241,9 +436,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
</div>
))}
</div>
)}
)))}
{tab === 'Feedback' && (
{tab === 'Feedback' && (guard || (live ? (
<FeedbackTab userId={c.userId} inboxId={inboxId} rows={live.feedback ?? []} />
) : (
<>
<div className="list-tight">
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => {
@ -270,8 +467,433 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
<Icon name="plus" /> Submit Scorecard
</button>
</>
)}
)))}
</div>
</Modal>
)
}
/* ------------------------------------------------------------------
Live tabs. Each owns its own form state and its own write, so a half-typed
note is not held by the modal shell and does not survive a tab switch.
------------------------------------------------------------------ */
function ResumeTab({ live }) {
const source = live.documents?.[0]?.name
if (!live.resume_text) {
return (
<EmptyState icon="file" title="No résumé text">
{source
? `${source} is attached but has not been parsed yet — run the match to extract it.`
: 'This candidate applied without an attachment we could read.'}
</EmptyState>
)
}
return (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<h3 style={{ marginBottom: 4 }}>{live.name}</h3>
<p className="text-muted">
{source ? `Extracted from ${source}` : 'Extracted from the application email'}
</p>
<div className="divider" />
<div className="text-sm" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6 }}>{live.resume_text}</div>
</div>
</div>
)
}
function TimelineTab({ live }) {
const events = useMemo(() => {
const out = []
if (live.applied) {
out.push({
icon: 'user-plus',
title: 'Application received',
at: live.applied,
desc: live.source ? `Applied via ${live.source}` : null,
})
}
if (live.matched_at) {
out.push({
icon: 'sparkles',
title: 'AI screening completed',
at: live.matched_at,
desc: live.match_error || live.match_summary || live.match_status,
})
}
for (const iv of live.interviews ?? []) {
out.push({
icon: 'calendar',
title: iv.interview_type || 'Interview',
at: iv.interview_date,
desc: iv.interview_status,
})
}
for (const a of live.activity ?? []) {
out.push({
icon: 'zap',
title: a.activity_type || 'Activity',
at: a.activity_date,
desc: a.description || a.activity_status,
})
}
for (const f of live.feedback ?? []) {
out.push({
icon: 'star',
title: f.review ? `Feedback: ${f.review}` : 'Feedback submitted',
at: f.created_at,
desc: f.reviewed_by_name ? `by ${f.reviewed_by_name}` : f.note,
})
}
return out.sort((a, b) => stamp(a.at) - stamp(b.at))
}, [live])
if (!events.length) {
return <EmptyState icon="clock" title="Nothing recorded yet">Activity appears here as the candidate moves.</EmptyState>
}
return (
<div className="timeline">
{events.map((e, i) => (
<div className="tl-item" key={`${e.title}-${i}`}>
<div className="tl-dot"><Icon name={e.icon} /></div>
<div className="tl-title">{e.title}</div>
<div className="tl-meta">{fmtWhen(e.at)}</div>
{e.desc && <div className="tl-desc">{e.desc}</div>}
</div>
))}
</div>
)
}
function InterviewTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
// interviews.interview_date and .interview_time are BOTH datetime columns,
// so the same instant goes to each rather than inventing a second one.
mutationFn: (instant) => candidatesApi.createInterview({
inboxId, date: instant, time: instant, type: form.type, status: form.status,
}),
success: 'Interview scheduled',
onDone: () => setForm({ type: INTERVIEW_TYPES[0], date: '', time: '', status: INTERVIEW_STATES[0] }),
})
function submit() {
const instant = toInstant(form.date, form.time)
if (!instant) {
toast('Pick a date for the interview', 'warning')
return
}
create.mutate(instant)
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((iv) => {
const clock = fmtClock(iv.interview_time)
return (
<div className="list-row" key={iv.id}>
<span className="kpi-icn i-blue" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="calendar" />
</span>
<div className="lr-main">
<div className="lr-title">{iv.interview_type || 'Interview'}</div>
<div className="lr-sub">{fmtWhen(iv.interview_date)}{clock ? ` · ${clock}` : ''}</div>
</div>
<div className="lr-right">
{iv.interview_status ? <Badge>{iv.interview_status}</Badge> : null}
</div>
</div>
)
})}
</div>
) : (
<EmptyState icon="calendar" title="No interviews scheduled">
Schedule the first round below.
</EmptyState>
)}
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Schedule an interview</div>
<div className="form-grid">
<div className="form-field">
<label>Type</label>
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
{INTERVIEW_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>Status</label>
<select value={form.status} onChange={(e) => set('status', e.target.value)}>
{INTERVIEW_STATES.map((s) => <option key={s}>{s}</option>)}
</select>
</div>
<div className="form-field">
<label>Date</label>
<input type="date" value={form.date} onChange={(e) => set('date', e.target.value)} />
</div>
<div className="form-field">
<label>Time</label>
<input type="time" value={form.time} onChange={(e) => set('time', e.target.value)} />
</div>
</div>
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Scheduling…' : 'Schedule Interview'}
</button>
</>
)
}
function NotesTab({ userId, rows }) {
const [text, setText] = useState('')
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createNote({ userId, note: text.trim() }),
success: 'Note saved',
onDone: () => setText(''),
})
return (
<>
<div className="form-field">
<label>Add a note</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Write a private note about this candidate…"
/>
</div>
<button
className="btn btn-primary btn-sm"
style={{ margin: '10px 0 18px' }}
disabled={!text.trim() || create.isPending}
onClick={() => create.mutate()}
>
<Icon name="plus" /> {create.isPending ? 'Saving…' : 'Add Note'}
</button>
{rows.length ? (
<div className="list-tight">
{rows.map((n) => (
<div className="list-row" key={n.id}>
<Avatar name={n.created_by_name || 'Unknown'} />
<div className="lr-main">
<div className="lr-title">{n.created_by_name || 'Unknown author'}</div>
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>{n.note}</div>
<div className="lr-sub">{fmtWhen(n.created_at)}</div>
</div>
</div>
))}
</div>
) : (
<EmptyState icon="edit" title="No notes yet">The first note on this candidate goes above.</EmptyState>
)}
</>
)
}
function ActivityTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ type: ACTIVITY_TYPES[0], description: '' })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createActivity({
inboxId, type: form.type, status: 'Logged', description: form.description.trim(),
}),
success: 'Activity logged',
onDone: () => setForm({ type: ACTIVITY_TYPES[0], description: '' }),
})
function submit() {
if (!form.description.trim()) {
toast('Describe what happened', 'warning')
return
}
create.mutate()
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((a) => {
const clock = fmtClock(a.activity_time)
return (
<div className="list-row" key={a.id}>
<span className="kpi-icn i-purple" style={{ width: 34, height: 34, borderRadius: 9 }}>
<Icon name="zap" />
</span>
<div className="lr-main">
<div className="lr-title">{a.activity_type || 'Activity'}</div>
{a.description && (
<div className="lr-sub" style={{ color: 'var(--text-2)', fontSize: 13 }}>{a.description}</div>
)}
<div className="lr-sub">{fmtWhen(a.activity_date)}{clock ? ` · ${clock}` : ''}</div>
</div>
<div className="lr-right">{a.activity_status ? <Badge>{a.activity_status}</Badge> : null}</div>
</div>
)
})}
</div>
) : (
<EmptyState icon="zap" title="No activity recorded">Log the first touchpoint below.</EmptyState>
)}
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Log activity</div>
<div className="form-grid">
<div className="form-field">
<label>Type</label>
<select value={form.type} onChange={(e) => set('type', e.target.value)}>
{ACTIVITY_TYPES.map((t) => <option key={t}>{t}</option>)}
</select>
</div>
<div className="form-field">
<label>What happened</label>
<input
value={form.description}
onChange={(e) => set('description', e.target.value)}
placeholder="Called to confirm availability"
/>
</div>
</div>
<button
className="btn btn-secondary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Logging…' : 'Log Activity'}
</button>
</>
)
}
function DocumentsTab({ rows }) {
if (!rows.length) {
return <EmptyState icon="file" title="No documents">This application arrived without attachments.</EmptyState>
}
return (
<>
<div className="list-tight">
{rows.map((d, i) => (
<div className="list-row" key={`${d.name}-${i}`}>
<span className="kpi-icn i-red" style={{ width: 38, height: 38, borderRadius: 10 }}>
<Icon name="file" />
</span>
<div className="lr-main">
<div className="lr-title">{d.name}</div>
<div className="lr-sub">{d.path || 'Stored with the application'}</div>
</div>
</div>
))}
</div>
{/* No download button: attachments live on the worker's filesystem and no
route serves them yet, so a button here could only lie. */}
<p className="text-muted text-sm" style={{ marginTop: 12 }}>
<Icon name="info" /> Attachments are stored server-side; download is not exposed yet.
</p>
</>
)
}
function FeedbackTab({ userId, inboxId, rows }) {
const { toast } = useToast()
const [form, setForm] = useState({ review: REVIEWS[0], score: '', note: '' })
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }))
const create = useProfileWrite({
userId,
mutationFn: () => candidatesApi.createFeedback({
inboxId,
review: form.review,
score: form.score === '' ? 0 : Number(form.score),
note: form.note.trim(),
}),
success: 'Scorecard submitted',
onDone: () => setForm({ review: REVIEWS[0], score: '', note: '' }),
})
function submit() {
const score = form.score === '' ? 0 : Number(form.score)
if (!Number.isFinite(score) || score < 0 || score > 100) {
toast('Score must be between 0 and 100', 'warning')
return
}
create.mutate()
}
return (
<>
{rows.length ? (
<div className="list-tight">
{rows.map((f) => (
<div className="list-row" key={f.id}>
<Avatar name={f.reviewed_by_name || 'Unknown'} />
<div className="lr-main">
<div className="lr-title">{f.reviewed_by_name || 'Unknown reviewer'}</div>
{f.note && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{f.note}</div>}
<div className="lr-sub">{fmtWhen(f.created_at)}{f.score ? ` · ${f.score}/100` : ''}</div>
</div>
<div className="lr-right">{f.review ? <Badge>{f.review}</Badge> : null}</div>
</div>
))}
</div>
) : (
<EmptyState icon="award" title="No scorecards yet">Be the first to review this candidate.</EmptyState>
)}
<div className="divider" />
<div className="form-section-title" style={{ marginTop: 0 }}>Submit a scorecard</div>
<div className="form-grid">
<div className="form-field">
<label>Recommendation</label>
<select value={form.review} onChange={(e) => set('review', e.target.value)}>
{REVIEWS.map((r) => <option key={r}>{r}</option>)}
</select>
</div>
<div className="form-field">
<label>Score (0100)</label>
<input
type="number"
min="0"
max="100"
value={form.score}
onChange={(e) => set('score', e.target.value)}
placeholder="80"
/>
</div>
</div>
<div className="form-field">
<label>Notes</label>
<textarea
value={form.note}
onChange={(e) => set('note', e.target.value)}
placeholder="What stood out, and what would you probe next round?"
/>
</div>
<button
className="btn btn-primary btn-sm"
style={{ marginTop: 10 }}
disabled={!inboxId || create.isPending}
onClick={submit}
>
<Icon name="plus" /> {create.isPending ? 'Submitting…' : 'Submit Scorecard'}
</button>
</>
)
}

View File

@ -524,7 +524,6 @@ export default function Inbox() {
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onParse={() => parseResume(selected)}
onAssign={() => setAssigning(selected)}
onMove={() => moveToPipeline(selected)}
onNote={() => setNoting(selected)}
onReject={() => reject(selected)}
@ -603,7 +602,8 @@ function orDash(value, suffix = '') {
return value == null || value === '' ? '—' : `${value}${suffix}`
}
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onMove, onNote, onReject }) {
const navigate = useNavigate()
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
// Every action below writes to a table column or an endpoint that does not
@ -700,8 +700,11 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onA
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<Icon name="sparkles" /> Parse Resume
</button>
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
<Icon name="users" /> Assign Recruiter
<button
className="btn btn-secondary"
onClick={() => navigate(`/matching?record=${i.id}`)}
>
<Icon name="target" /> Assign Job
</button>
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
<Icon name="layers" /> Move to Pipeline

View File

@ -0,0 +1,868 @@
/* ============================================================
Job Matching assign each inbound application to exactly one job post.
Queue layout mirrors Inbox (Tabs over a .split). The AI's suggested_job_post_ids
land here as a radiogroup; Assign writes assigned_job_post_id. Nothing is
marked read that stays Inbox's job so this page cannot silently move the
inbox nav badge.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon } 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 inboxApi from '../api/inbox'
import * as jobPostsApi from '../api/jobPosts'
import {
avatarColor, fmtDate, initials as initialsOf, inboxSources, sourceMeta,
} from '../data/seed'
const TABS = [
{ key: 'needs', label: 'Needs assignment' },
{ key: 'assigned', label: 'Assigned' },
{ key: 'none', label: 'No suggestions' },
{ key: 'all', label: 'All' },
]
const TAB_FILTERS = {
needs: { assigned: false },
assigned: { assigned: true },
none: {},
all: {},
}
const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
function parseDate(value) {
if (!value) return null
const d = new Date(value)
return Number.isNaN(d.getTime()) ? null : d
}
function sourceFrom(messageTo) {
const raw = (messageTo || '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: raw.split(',')[0].trim(), sourceMeta: null }
}
function SourceChip({ item }) {
return (
<span className="source-chip" style={{ '--chip': item.sourceMeta?.color }}>
<span className="source-dot" />
{item.source}
</span>
)
}
function htmlToText(value) {
const raw = (value || '').trim()
if (!raw) return ''
if (!/<[a-z!/]/i.test(raw)) return raw
const withBreaks = raw
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|li|tr|h[1-6]|blockquote|table)\s*>/gi, '\n')
const doc = new DOMParser().parseFromString(withBreaks, 'text/html')
doc.querySelectorAll('script, style, head').forEach((n) => n.remove())
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
}
/** Requirement chip lights green when the resume text contains it (client-side). */
function reqInResume(req, resumeText) {
if (!req || !resumeText) return false
const needle = String(req).trim().toLowerCase()
if (!needle) return false
return resumeText.toLowerCase().includes(needle)
}
function mapApplication(row) {
const name = row.name || row.email || 'Unknown'
const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : []
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
resumeStatus: row.resume_status || RESUME_STATUS[row.match_status] || 'Pending',
resumeText: row.resume_text || '',
suggestedIds: suggested.map(String),
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
const rows = Array.isArray(res?.data) ? res.data : []
return { rows: rows.map(mapApplication), total: res?.total ?? rows.length }
}
async function fetchDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
const row = res?.data
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.fromEmail || '',
position: row.subject || '(no subject)',
...sourceFrom(row.message_to),
body: htmlToText(row.body),
resumeText: row.resume_text || '',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
processing: row.unread ? 'Unread' : 'Read',
suggestedIds: (row.suggested_job_post_ids || []).map(String),
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
matchStatus: row.match_status || null,
matchSummary: row.match_summary || '',
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
}
}
function AssignmentBadge({ item, titleById }) {
if (item.matchStatus === 'processing') {
return <Badge className="b-gray">Matching</Badge>
}
if (item.assignedId) {
const title = titleById.get(item.assignedId) || 'Assigned'
return <Badge className="b-green">{title}</Badge>
}
const n = item.suggestedIds.length
if (n > 0) return <Badge className="b-blue">{n} suggested</Badge>
return <Badge className="b-amber">No match</Badge>
}
function JobCard({ post, rank, selected, onSelect, resumeText, manual }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
return (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !unavailable && onSelect(post.id)}
onKeyDown={(e) => {
if (unavailable) return
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(post.id)
}
}}
style={{
cursor: unavailable ? 'not-allowed' : 'pointer',
opacity: unavailable ? 0.55 : 1,
borderColor: selected ? 'var(--primary)' : undefined,
boxShadow: selected ? 'var(--ring)' : undefined,
marginBottom: 8,
alignItems: 'flex-start',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
<Badge className="b-gray">Unavailable</Badge>
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && (post.requirements || []).length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => {
const hit = reqInResume(req, resumeText)
return (
<span
key={req}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{req}
</span>
)
})}
</div>
)}
</div>
</div>
)
}
function PickRoleModal({ onClose, onPick }) {
const [q, setQ] = useState('')
const { data = [], isPending, isError, error } = useQuery({
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
queryFn: async () => {
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
return Array.isArray(res?.data) ? res.data : []
},
})
return (
<Modal
title="Choose a different role"
subtitle="Search open job posts"
size="modal-lg"
onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
>
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
</div>
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
{isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(error, 'Request failed')}
</EmptyState>
)}
{!isPending && !isError && data.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<div className="list-tight">
{data.map((p) => (
<div
key={p.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => { onPick(p); onClose() }}
>
<div className="lr-main">
<div className="lr-title">{p.title}</div>
<div className="lr-sub">
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge>{p.status}</Badge>
</div>
))}
</div>
</Modal>
)
}
export default function Matching() {
const { toast } = useToast()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const qc = useQueryClient()
const [searchParams, setSearchParams] = useSearchParams()
const deepLink = searchParams.get('record')
const [tab, setTab] = useState('needs')
const [selectedId, setSelectedId] = useState(deepLink || null)
const [q, setQ] = useState('')
const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false)
const [whyOpen, setWhyOpen] = useState(false)
const tabFilter = TAB_FILTERS[tab] ?? {}
const listQuery = useQuery({
queryKey: qk.mailbox.assignments({ ...tabFilter, tab }),
queryFn: () => fetchApplications(tabFilter),
})
const needsCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: false }),
queryFn: async () => (await inboxApi.listApplications({ assigned: false, top: 1 }))?.total ?? 0,
})
const assignedCount = useQuery({
queryKey: qk.mailbox.assignments({ assigned: true }),
queryFn: async () => (await inboxApi.listApplications({ assigned: true, top: 1 }))?.total ?? 0,
})
const allCount = useQuery({
queryKey: qk.mailbox.assignments({}),
queryFn: async () => (await inboxApi.listApplications({ top: 1 }))?.total ?? 0,
})
const noneCountQuery = useQuery({
queryKey: qk.mailbox.assignments({ kind: 'none' }),
queryFn: async () => {
const res = await fetchApplications({})
return res.rows.filter((r) => !r.assignedId && r.suggestedIds.length === 0).length
},
})
const rows = listQuery.data?.rows ?? []
const filtered = useMemo(() => {
let list = rows
if (tab === 'none') {
list = list.filter((r) => !r.assignedId && r.suggestedIds.length === 0)
}
const needle = q.trim().toLowerCase()
if (!needle) return list
return list.filter((r) => (
r.name.toLowerCase().includes(needle)
|| r.position.toLowerCase().includes(needle)
|| r.email.toLowerCase().includes(needle)
))
}, [rows, tab, q])
const noneCount = noneCountQuery.data ?? 0
// Preselect deep link once, then clear the query so refresh doesn't re-pin.
useEffect(() => {
if (!deepLink) return undefined
setSelectedId(deepLink)
setSearchParams({}, { replace: true })
return undefined
}, [deepLink, setSearchParams])
const detailQuery = useQuery({
queryKey: qk.mailbox.message(selectedId),
queryFn: () => fetchDetail(selectedId),
enabled: Boolean(selectedId),
})
const detail = detailQuery.data
const listRow = filtered.find((r) => r.id === selectedId) || rows.find((r) => r.id === selectedId)
// Hydrate titles for list badges (assigned + suggestions) in one call.
const hydrateIds = useMemo(() => {
const ids = new Set()
for (const r of rows) {
if (r.assignedId) ids.add(r.assignedId)
for (const id of r.suggestedIds) ids.add(id)
}
return [...ids]
}, [rows])
const titlesQuery = useQuery({
queryKey: qk.jobPosts.list({ ids: hydrateIds }),
queryFn: async () => {
if (!hydrateIds.length) return []
const res = await jobPostsApi.list({ ids: hydrateIds, activeOnly: false })
return Array.isArray(res?.data) ? res.data : []
},
enabled: hydrateIds.length > 0,
})
const titleById = useMemo(() => {
const map = new Map()
for (const p of titlesQuery.data || []) map.set(String(p.id), p.title)
return map
}, [titlesQuery.data])
// Reset local selection when the selected application changes.
useEffect(() => {
setManualPost(null)
setWhyOpen(false)
if (detail?.assignedId) setSelection(detail.assignedId)
else if (detail?.suggestedIds?.[0]) setSelection(detail.suggestedIds[0])
else setSelection(null)
}, [detail?.id, detail?.assignedId, detail?.suggestedIds])
const suggestionCards = useMemo(() => {
const fromDetail = detail?.suggestedPosts || []
const byId = new Map(fromDetail.map((p) => [String(p.id), p]))
const ids = detail?.suggestedIds || listRow?.suggestedIds || []
return ids.map((id, i) => ({
rank: i + 1,
post: byId.get(id) || { id, unavailable: true },
}))
}, [detail, listRow])
const selectedPost = useMemo(() => {
if (!selection) return null
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
if (detail?.assignedPost && String(detail.assignedPost.id) === String(selection)) {
return detail.assignedPost
}
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
return hit?.post || null
}, [selection, manualPost, detail, suggestionCards])
const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
onMutate: async ({ recordId, jobPostId }) => {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
await qc.cancelQueries({ queryKey: ['mailbox', 'assignments'] })
return { recordId, jobPostId }
},
onError: (err) => {
toast(friendlyAuthError(err, 'Could not assign job post.'), 'error')
},
onSuccess: (_res, vars) => {
const name = listRow?.name || detail?.name || 'Candidate'
const title = selectedPost?.title || titleById.get(vars.jobPostId) || 'role'
if (vars.jobPostId) toast(`${name}${title}`, 'success')
else toast(`${name} unassigned`, 'success')
},
onSettled: async (_res, _err, vars) => {
await qc.invalidateQueries({ queryKey: qk.mailbox.all() })
await qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
await qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
await qc.invalidateQueries({ queryKey: qk.candidates.all() })
// Auto-advance only on the Needs assignment tab after a real assign.
if (tab === 'needs' && vars.jobPostId) {
const idx = filtered.findIndex((r) => r.id === vars.recordId)
const next = filtered[idx + 1] || filtered[idx - 1] || null
setSelectedId(next?.id || null)
}
},
})
const rematchMutation = useMutation({
mutationFn: (recordId) => inboxApi.rematch(recordId),
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
onSuccess: () => toast('Match re-queued', 'success'),
onSettled: (_r, _e, recordId) => {
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
qc.invalidateQueries({ queryKey: ['mailbox', 'assignments'] })
},
})
// Keyboard: j/k move queue, 15 pick suggestion, Enter assigns, Esc clears.
useEffect(() => {
const onKey = (e) => {
const tag = e.target?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'j' || e.key === 'ArrowDown') {
e.preventDefault()
const idx = filtered.findIndex((r) => r.id === selectedId)
const next = filtered[Math.min(filtered.length - 1, (idx < 0 ? 0 : idx + 1))]
if (next) setSelectedId(next.id)
} else if (e.key === 'k' || e.key === 'ArrowUp') {
e.preventDefault()
const idx = filtered.findIndex((r) => r.id === selectedId)
const next = filtered[Math.max(0, (idx < 0 ? 0 : idx - 1))]
if (next) setSelectedId(next.id)
} else if (e.key >= '1' && e.key <= '5') {
const card = suggestionCards[Number(e.key) - 1]
if (card && !card.post.unavailable) setSelection(String(card.post.id))
} else if (e.key === 'Enter' && canEdit && selection && selection !== detail?.assignedId) {
e.preventDefault()
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
} else if (e.key === 'Escape') {
setSelection(detail?.assignedId || null)
setManualPost(null)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [filtered, selectedId, suggestionCards, canEdit, selection, detail, assignMutation])
const counts = {
needs: needsCount.data ?? 0,
assigned: assignedCount.data ?? 0,
none: noneCount,
all: allCount.data ?? 0,
}
const resumeText = detail?.resumeText || listRow?.resumeText || ''
const matchFailed = ['failed', 'no_text', 'dlq'].includes(detail?.matchStatus)
return (
<div className="page">
<div className="page-head">
<div>
<h1>Job Matching</h1>
<p className="page-sub">Route applications to the right open role</p>
</div>
</div>
{!canEdit && (
<div className="alert alert-danger" style={{ marginBottom: 14 }}>
Your account does not hold <code>inbox.edit</code>, which the server requires to
assign, unassign, or retry a match. Controls below stay disabled.
</div>
)}
<div className="card">
<div style={{ padding: '0 8px', borderBottom: '1px solid var(--border)' }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
tabs={TABS.map((t) => ({
key: t.key,
label: t.label,
count: counts[t.key],
}))}
/>
</div>
<div className="split">
<div className="split-list">
<div style={{ padding: '12px 16px', borderBottom: '1px solid var(--border)' }}>
<div className="toolbar-search" style={{ maxWidth: 'none' }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search…" />
</div>
</div>
<div>
{listQuery.isPending && (
<EmptyState icon="target" title="Loading…">Fetching applications.</EmptyState>
)}
{listQuery.isError && (
<EmptyState icon="alert" title="Couldnt load queue">
{friendlyAuthError(listQuery.error, 'Request failed')}
</EmptyState>
)}
{listQuery.isSuccess && filtered.length === 0 && (
<EmptyState icon="check-circle" title="Queue clear">
{tab === 'needs'
? 'Every application in this view has a role.'
: 'Nothing matches this filter.'}
</EmptyState>
)}
{filtered.map((i) => (
<div
key={i.id}
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => setSelectedId(i.id)}
>
<Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main">
<div className="ii-name">{i.name}</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-meta">
<SourceChip item={i} />
<AssignmentBadge item={i} titleById={titleById} />
</div>
</div>
</div>
))}
</div>
</div>
<div className="split-detail">
{!selectedId ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="target" title="Select an application">
Choose an item from the list to review suggestions and assign a role.
</EmptyState>
</div>
) : detailQuery.isError ? (
<div style={{ padding: '100px 20px' }}>
<EmptyState icon="alert" title="Couldnt load this application">
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : (
<MatchingWorkspace
listRow={listRow}
detail={detail}
loading={detailQuery.isPending}
canEdit={canEdit}
selection={selection}
setSelection={setSelection}
manualPost={manualPost}
suggestionCards={suggestionCards}
selectedPost={selectedPost}
resumeText={resumeText}
whyOpen={whyOpen}
setWhyOpen={setWhyOpen}
matchFailed={matchFailed}
onPickManual={() => setShowPicker(true)}
onSkip={() => {
const idx = filtered.findIndex((r) => r.id === selectedId)
const next = filtered[idx + 1]
if (next) setSelectedId(next.id)
}}
onAssign={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: selectedId, jobPostId: selection })
}}
onUnassign={() => {
if (!canEdit) return
assignMutation.mutate({ recordId: selectedId, jobPostId: null })
}}
onChange={() => setShowPicker(true)}
onRematch={() => rematchMutation.mutate(selectedId)}
assigning={assignMutation.isPending}
rematching={rematchMutation.isPending}
/>
)}
</div>
</div>
</div>
{showPicker && (
<PickRoleModal
onClose={() => setShowPicker(false)}
onPick={(post) => {
setManualPost(post)
setSelection(String(post.id))
}}
/>
)}
</div>
)
}
function MatchingWorkspace({
listRow,
detail,
loading,
canEdit,
selection,
setSelection,
manualPost,
suggestionCards,
selectedPost,
resumeText,
whyOpen,
setWhyOpen,
matchFailed,
onPickManual,
onSkip,
onAssign,
onUnassign,
onChange,
onRematch,
assigning,
rematching,
}) {
const i = {
name: detail?.name || listRow?.name || '…',
initials: detail?.initials || listRow?.initials,
color: detail?.color || listRow?.color,
position: detail?.position || listRow?.position,
source: detail?.source || listRow?.source,
sourceMeta: detail?.sourceMeta || listRow?.sourceMeta,
processing: detail?.processing || listRow?.processing,
resumeStatus: detail?.resumeStatus || listRow?.resumeStatus,
}
const assigned = detail?.assignedPost
const currentId = detail?.assignedId
const canAssign = canEdit && selection && selection !== currentId && !assigning
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
<Avatar name={i.name} initials={i.initials} color={i.color} className="avatar-lg" />
<div style={{ flex: 1 }}>
<div className="ph-name" style={{ fontSize: 19 }}>{i.name}</div>
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} />{' '}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>
{loading && <span className="cell-sub">Loading details</span>}
</div>
</div>
</div>
{assigned && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--primary-soft)',
border: '1px solid var(--primary-border)',
marginBottom: 18,
}}
>
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div className="flex items-center gap-8">
<Icon name="check-circle" />
<div>
<div>Assigned to <b>{assigned.title}</b></div>
<div className="cell-sub">
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
<div className="flex gap-8">
<button className="btn btn-secondary btn-sm" disabled={!canEdit} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onChange}>
Change
</button>
<button className="btn btn-ghost btn-sm" disabled={!canEdit || assigning} title={!canEdit ? 'Requires inbox.edit' : undefined} onClick={onUnassign}>
Unassign
</button>
</div>
</div>
</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 18,
alignItems: 'start',
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
{matchFailed ? (
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{detail?.matchError || 'Matching failed for this application.'}</div>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onRematch}
>
<Icon name="sparkles" /> Retry match
</button>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}>
{detail?.matchSummary || listRow?.matchSummary || 'No match summary yet.'}
</p>
{detail?.matchedAt && (
<div className="cell-sub">Matched {fmtDate(detail.matchedAt)}</div>
)}
{(detail?.matchReasoning || listRow?.matchReasoning) && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 8, paddingLeft: 0 }}
onClick={() => setWhyOpen((v) => !v)}
>
{whyOpen ? '▾' : '▸'} Why these roles?
</button>
)}
{whyOpen && (
<p className="text-muted text-sm" style={{ marginTop: 8 }}>
{detail?.matchReasoning || listRow?.matchReasoning}
</p>
)}
</div>
)}
<div className="fw-600" style={{ marginBottom: 6 }}>Resume text</div>
<pre className="resume-thumb" style={{ maxHeight: 220, marginBottom: 16 }}>
{resumeText || 'Resume text not extracted yet.'}
</pre>
{(detail?.body) && (
<>
<div className="fw-600" style={{ marginBottom: 6 }}>Email body</div>
<pre className="resume-thumb" style={{ maxHeight: 160 }}>
{detail.body}
</pre>
</>
)}
</div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div>
{suggestionCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No suggested roles">
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematching}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onRematch}
>
Retry match
</button>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onPickManual}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
suggestionCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(id)}
resumeText={resumeText}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(id)}
resumeText={resumeText}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onPickManual}
>
Choose a different role
</button>
</div>
</div>
<div
className="flex gap-8"
style={{
marginTop: 20,
paddingTop: 16,
borderTop: '1px solid var(--border)',
justifyContent: 'space-between',
flexWrap: 'wrap',
}}
>
<button className="btn btn-secondary" onClick={onSkip}>Skip</button>
<button
className="btn btn-primary"
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={onAssign}
>
{selectedPost?.title
? `Assign to ${selectedPost.title}`
: 'Assign'}
</button>
</div>
</div>
)
}

View File

@ -13,6 +13,13 @@
Clicking a card opens CandidateProfile in place. It used to deep-link into
/candidates, which stopped resolving once the ids became real user_ids.
The card is seed-overlaid, but the MODAL is not: it re-reads the candidate by
`userId` through GET /candidate/fetch?user_id=, which is a far richer payload
than the list rows résumé text, the agent's verdict, documents, and the
interviews / notes / activity / feedback collections, all writable from their
own tabs. That switch happens inside CandidateProfile; passing `userId` is the
whole trigger.
============================================================ */
import { useMemo, useState } from 'react'
@ -52,10 +59,10 @@ function years(value) {
/**
* 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`.
* `id` deliberately stays the SEED id: the favourite/advance seed mutations key
* off it, so a UUID here would silently drop those writes. The real identifier
* rides along on `userId`, and that is what the profile modal reads its live
* record with.
*/
function merge(row, template) {
const name = row.name || template.name
@ -125,6 +132,8 @@ export default function TalentPool() {
// 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.
// Favourite is a real PATCH once the modal holds a userId; this seed path is
// the fallback for inbox rows that were never linked to a user.
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))