diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py new file mode 100644 index 0000000..b2bf69e --- /dev/null +++ b/backend/employment_agent/decorators.py @@ -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() diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py new file mode 100644 index 0000000..7f74380 --- /dev/null +++ b/backend/employment_agent/execute_agent.py @@ -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 diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py new file mode 100644 index 0000000..d3175b5 --- /dev/null +++ b/backend/employment_agent/prompt.py @@ -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) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 9f279c5..34ff814 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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)) \ No newline at end of file + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/inbox/enums.py b/backend/inbox/enums.py index 992c23b..f90f26e 100644 --- a/backend/inbox/enums.py +++ b/backend/inbox/enums.py @@ -8,4 +8,9 @@ class Candidate_application_Status(str, Enum): APPROVED="APPROVED" REJECTED="REJECTED" ONHOLD="ONHOLD" - CLOSED="CLOSED" \ No newline at end of file + CLOSED="CLOSED" + SCREENING="SCREENING" + ASSESSMENT="ASSESSMENT" + INTERVIEW="INTERVIEW" + OFFER="OFFER" + HIRED="HIRED" \ No newline at end of file diff --git a/backend/inbox/models.py b/backend/inbox/models.py index d1e711c..8e65cff 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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) diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 9548c6d..7af0ec8 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -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"] diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 4419fd6..b8cd123 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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, } diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index 2d59cc9..ae48a0b 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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, + } diff --git a/backend/inbox/views.py b/backend/inbox/views.py index ea6a12d..0b87e4d 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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) diff --git a/backend/job/activity/serializers.py b/backend/job/activity/serializers.py new file mode 100644 index 0000000..c14f962 --- /dev/null +++ b/backend/job/activity/serializers.py @@ -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, + } diff --git a/backend/job/activity/views.py b/backend/job/activity/views.py new file mode 100644 index 0000000..7339692 --- /dev/null +++ b/backend/job/activity/views.py @@ -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) diff --git a/backend/job/app.py b/backend/job/app.py index 2477bab..07f83e2 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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)) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 482916b..eeaf849 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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 diff --git a/backend/job/candidate/plugins.py b/backend/job/candidate/plugins.py index f545b15..94d02fa 100644 --- a/backend/job/candidate/plugins.py +++ b/backend/job/candidate/plugins.py @@ -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 + diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 9e6f6b7..fbebde3 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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 diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index abdcca3..e709a7c 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 diff --git a/backend/job/feedback/serializers.py b/backend/job/feedback/serializers.py new file mode 100644 index 0000000..6248a85 --- /dev/null +++ b/backend/job/feedback/serializers.py @@ -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, + } diff --git a/backend/job/feedback/views.py b/backend/job/feedback/views.py new file mode 100644 index 0000000..b8b1b61 --- /dev/null +++ b/backend/job/feedback/views.py @@ -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) diff --git a/backend/job/interviews/serializers.py b/backend/job/interviews/serializers.py new file mode 100644 index 0000000..eb319a9 --- /dev/null +++ b/backend/job/interviews/serializers.py @@ -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, + } diff --git a/backend/job/interviews/views.py b/backend/job/interviews/views.py new file mode 100644 index 0000000..6956da4 --- /dev/null +++ b/backend/job/interviews/views.py @@ -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) diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index d87c564..2a32b89 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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) diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index d58e3ad..53d737a 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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, } diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 3d64f93..a01bcb0 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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 diff --git a/backend/job/notes/serializers.py b/backend/job/notes/serializers.py new file mode 100644 index 0000000..9cd2f2f --- /dev/null +++ b/backend/job/notes/serializers.py @@ -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, + } diff --git a/backend/job/notes/views.py b/backend/job/notes/views.py new file mode 100644 index 0000000..433431c --- /dev/null +++ b/backend/job/notes/views.py @@ -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) diff --git a/backend/users/models.py b/backend/users/models.py index bbfa0a1..d84b1b6 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index bd7eb2e..b7313a4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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')), diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index d5f4914..9832a6a 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -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, diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 6c30a9a..69756cc 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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 }, + }) +} diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index beee5c6..4c3bd23 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -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' }) +} diff --git a/frontend/src/api/jobPosts.js b/frontend/src/api/jobPosts.js new file mode 100644 index 0000000..6d49764 --- /dev/null +++ b/frontend/src/api/jobPosts.js @@ -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, + }, + }) +} diff --git a/frontend/src/app/Sidebar.jsx b/frontend/src/app/Sidebar.jsx index fbbcea7..b79370a 100644 --- a/frontend/src/app/Sidebar.jsx +++ b/frontend/src/app/Sidebar.jsx @@ -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 >
- +
TalentFlow diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index 521439f..aace388 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -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' }, diff --git a/frontend/src/app/useShell.js b/frontend/src/app/useShell.js index 8b98fcd..4e55aa4 100644 --- a/frontend/src/app/useShell.js +++ b/frontend/src/app/useShell.js @@ -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, } } diff --git a/frontend/src/components/BrandMark.jsx b/frontend/src/components/BrandMark.jsx index 352f37f..e8ad489 100644 --- a/frontend/src/components/BrandMark.jsx +++ b/frontend/src/components/BrandMark.jsx @@ -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 ( + + ) +} + export default function BrandMark({ size = 'md', showName = true }) { const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo' return (
- +
{showName ? TalentFlow : null}
diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 9e3ea6b..007f409 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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 --- diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index cf837a9..a741dc6 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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() +} + +/** + -> 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 ( +
+
{label}
+
{val === 0 || val ? val : '—'}
+
+ ) +} + +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 ? ( + Fetching the full record. + ) : detail.isError ? ( + + {friendlyAuthError(detail.error, 'Please try again.')} + + ) : !live ? ( + This candidate is no longer in the pipeline. + ) : null + return ( - )} + )))} - {tab === 'Timeline' && ( + {tab === 'Timeline' && (guard || (live ? ( + + ) : (
{[ { 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
))}
- )} + )))} - {tab === 'Interview' && ( + {tab === 'Interview' && (guard || (live ? ( + + ) : ( candidateInterviews.length ? (
{candidateInterviews.map((iv) => ( @@ -167,9 +356,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT Schedule an interview to get started. ) - )} + )))} - {tab === 'Notes' && ( + {tab === 'Notes' && (guard || (live ? ( + + ) : ( <>
@@ -201,9 +392,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
- )} + )))} - {tab === 'Activity' && ( + {tab === 'Activity' && (guard || (live ? ( + + ) : (
{[ { 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
))}
- )} + )))} - {tab === 'Documents' && ( + {tab === 'Documents' && (guard || (live ? ( + + ) : (
{[ { 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
))} - )} + )))} - {tab === 'Feedback' && ( + {tab === 'Feedback' && (guard || (live ? ( + + ) : ( <>
{['Strong Hire', 'Hire', 'Lean Hire'].map((score, i) => { @@ -270,8 +467,433 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT Submit Scorecard - )} + )))}
) } + +/* ------------------------------------------------------------------ + 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 ( + + {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.'} + + ) + } + return ( +
+
+

{live.name}

+

+ {source ? `Extracted from ${source}` : 'Extracted from the application email'} +

+
+
{live.resume_text}
+
+
+ ) +} + +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 Activity appears here as the candidate moves. + } + + return ( +
+ {events.map((e, i) => ( +
+
+
{e.title}
+
{fmtWhen(e.at)}
+ {e.desc &&
{e.desc}
} +
+ ))} +
+ ) +} + +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 ? ( +
+ {rows.map((iv) => { + const clock = fmtClock(iv.interview_time) + return ( +
+ + + +
+
{iv.interview_type || 'Interview'}
+
{fmtWhen(iv.interview_date)}{clock ? ` · ${clock}` : ''}
+
+
+ {iv.interview_status ? {iv.interview_status} : null} +
+
+ ) + })} +
+ ) : ( + + Schedule the first round below. + + )} + +
+
Schedule an interview
+
+
+ + +
+
+ + +
+
+ + set('date', e.target.value)} /> +
+
+ + set('time', e.target.value)} /> +
+
+ + + ) +} + +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 ( + <> +
+ +