From 3e87e1a0bcc06676defe2bb34c8cc9c75270ddb9 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 14:01:19 +0500 Subject: [PATCH 1/6] enumbs added --- backend/inbox/app.py | 2 +- backend/inbox/enums.py | 7 ++++++- backend/inbox/views.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 9f279c5..9d3c7fd 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -134,7 +134,7 @@ async def get_all_applications( try: service=Email(session=session) - if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: + 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) total=await service.count_inbox_messages(search, application_status=application_status) return JSONResponse(content={"data":items,"total":total,"status_code":200}) 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/views.py b/backend/inbox/views.py index ea6a12d..cfff765 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -95,7 +95,7 @@ class Email: 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: + 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) elif isread==False: messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread) From e717dbf704ad6869fa94e1e0f89a10de26a72d96 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 16:42:55 +0500 Subject: [PATCH 2/6] . --- backend/employment_agent/decorators.py | 75 +++++++++++++++++++++++ backend/employment_agent/execute_agent.py | 27 ++++++++ backend/employment_agent/prompt.py | 37 +++++++++++ backend/inbox/models.py | 46 +++++++++++++- backend/inbox/plugins.py | 13 ++++ backend/inbox/serializers.py | 11 ++-- backend/inbox/tasks.py | 16 ++++- backend/job/candidate/models.py | 71 +++++++++++++++++++-- backend/job/candidate/serializers.py | 1 + backend/users/models.py | 17 +++-- 10 files changed, 295 insertions(+), 19 deletions(-) create mode 100644 backend/employment_agent/decorators.py create mode 100644 backend/employment_agent/execute_agent.py create mode 100644 backend/employment_agent/prompt.py 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/models.py b/backend/inbox/models.py index d1e711c..492becb 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1,3 +1,4 @@ +from ast import List import logging import os import uuid @@ -15,7 +16,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select, true -from users.models import Users +from job.candidate.models import Interviews +from users.models import Users, Activity, Notes from users.plugins import hash_password load_dotenv() @@ -43,8 +45,35 @@ class Inbox(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) + + favorite:Optional[bool] = Field(default=False) + rating:Optional[float] = Field(default=0.0) + notes_id: Optional[uuid.UUID] = Field( + default=None, + foreign_key="notes.id", + ) + notes: List[Notes] = Relationship( + back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"} + ) - user: Optional[Users] = Relationship( + interview_id: Optional[uuid.UUID] = Field( + default=None, + foreign_key="interviews.id", + ) + interview: List[Interviews] = Relationship( + back_populates="inbox", + sa_relationship_kwargs={"lazy": "selectin"} + ) + + activity_id: Optional[uuid.UUID] = Field( + default=None, + foreign_key="activity.id" + ) + activity: Optional[Activity] = Relationship( + back_populates="inbox", + ) + Optional[Users] = Relationship( back_populates="inbox", sa_relationship_kwargs={"lazy": "joined"}, ) @@ -140,7 +169,9 @@ class Inbox_Messages(SQLModel, table=True): 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 +203,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 +218,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 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..4b725fc 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -76,10 +76,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), @@ -96,8 +96,9 @@ def serialize_application(message: Inbox_Messages) -> dict: "has_attachment": message.attachment, "resume_text": message.resume_text, "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/job/candidate/models.py b/backend/job/candidate/models.py index 482916b..5312f26 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1,6 +1,67 @@ -# from sqlmodel import SQLModel, Field -# from uuid import UUID, uuid4 -# from datetime import datetime -# from enum import Enum +from time import timezone +from token import OP +from sqlmodel import SQLModel, Field +from uuid import UUID, uuid4 +from datetime import datetime +from enum import Enum +import uuid +from typing import Optional,List +from sqlmodel import Relationship +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from inbox.models import Inbox + from users.models import Users -# class CV_extraction(SQLModel,table=True): +class Interviews(SQLModel,table=True): + __tablename__ = "interviews" + id: UUID = Field(default_factory=uuid4, primary_key=True) + interview_date: datetime = Field(default_factory=datetime.now) + interview_time: datetime = Field(default_factory=datetime.now) + interview_type: str = Field(default="") + interview_status: str = Field(default="") + inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") + inbox: Optional[Inbox] = Relationship(back_populates="interview", + sa_relationship_kwargs={"lazy": "joined"} + ) + + +class Notes(SQLModel,table=True): + __tablename__ = "notes" + id: UUID = Field(default_factory=uuid4, primary_key=True) + note: str = Field(default="") + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") + inbox: Optional[Inbox] = Relationship(back_populates="notes", + sa_relationship_kwargs={"lazy": "joined"} + ) + user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id") + user: Optional[Users] = Relationship(back_populates="notes", + sa_relationship_kwargs={"lazy": "joined"} + ) + +class Activity(SQLModel,table=True): + __tablename__ = "activity" + id: UUID = Field(default_factory=uuid4, primary_key=True) + activity_type: str = Field(default="") + activity_date: datetime = Field(default_factory=datetime.now) + activity_time: datetime = Field(default_factory=datetime.now) + activity_status: str = Field(default="") + inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") + inbox: Optional[Inbox] = Relationship(back_populates="activity", + sa_relationship_kwargs={"lazy": "joined"} + ) + +class Feedback(SQLModel,table=True): + __tablename__ = "feedback" + id: UUID = Field(default_factory=uuid4, primary_key=True) + review: str = Field(default="") + financial_status: str = Field(default="") + score: float = Field(default=0.0) + note:str=Field(default=None) + created_at:datetime=Field(default_factory=datetime.now(timezone.utc)) + updated_at:datetime=Field(default_factory=datetime.now(timezone.utc)) + reviewed_by:Optional[uuid.UUID]=Field(default=None,foreign_key="users.id") + user:Optional[Users]=Relationship(back_populates="feedback", + sa_relationship_kwargs={"lazy":"joined"} + ) \ No newline at end of file diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 9e6f6b7..79b80fd 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -20,6 +20,7 @@ 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 [], "match_summary": message.match_summary if message else None, diff --git a/backend/users/models.py b/backend/users/models.py index bbfa0a1..d578548 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -1,18 +1,19 @@ 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 from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select +from backend.job.candidate.models import Feedback from role.models import Roles from job.job_post.models import JobPosts if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module from inbox.models import Inbox - + from job.candidate.models import Notes class Users(SQLModel, table=True): __tablename__ = "users" @@ -26,15 +27,23 @@ 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:Optional[Feedback]=Relationship(back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + notes: Optional[Notes] = Relationship(back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, + ) + password: str + created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) is_active: bool = Field(default=False) From c726bbacbc0a0be295adff710445a33c737b6c5b Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 17:48:00 +0500 Subject: [PATCH 3/6] pedning testinf --- backend/inbox/models.py | 101 +++++++--- backend/job/activity/serializers.py | 10 + backend/job/activity/views.py | 55 ++++++ backend/job/app.py | 273 ++++++++++++++++++++++++- backend/job/candidate/models.py | 275 ++++++++++++++++++++++---- backend/job/candidate/plugins.py | 43 ++++ backend/job/candidate/serializers.py | 43 +++- backend/job/candidate/views.py | 95 ++++++++- backend/job/feedback/serializers.py | 14 ++ backend/job/feedback/views.py | 63 ++++++ backend/job/interviews/serializers.py | 9 + backend/job/interviews/views.py | 42 ++++ backend/job/job_post/serializers.py | 1 + backend/job/notes/serializers.py | 11 ++ backend/job/notes/views.py | 62 ++++++ backend/users/models.py | 21 +- 16 files changed, 1034 insertions(+), 84 deletions(-) create mode 100644 backend/job/activity/serializers.py create mode 100644 backend/job/activity/views.py create mode 100644 backend/job/feedback/serializers.py create mode 100644 backend/job/feedback/views.py create mode 100644 backend/job/interviews/serializers.py create mode 100644 backend/job/interviews/views.py create mode 100644 backend/job/notes/serializers.py create mode 100644 backend/job/notes/views.py diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 492becb..bb2bb7a 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -1,9 +1,8 @@ -from ast import List 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 @@ -16,8 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select, true -from job.candidate.models import Interviews -from users.models import Users, Activity, Notes +from job.candidate.models import Activity, Feedback, Interviews +from users.models import Users from users.plugins import hash_password load_dotenv() @@ -45,35 +44,24 @@ class Inbox(SQLModel, table=True): created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) - - favorite:Optional[bool] = Field(default=False) - rating:Optional[float] = Field(default=0.0) - notes_id: Optional[uuid.UUID] = Field( - default=None, - foreign_key="notes.id", - ) - notes: List[Notes] = Relationship( - back_populates="inbox", - sa_relationship_kwargs={"lazy": "selectin"} - ) - interview_id: Optional[uuid.UUID] = Field( - default=None, - foreign_key="interviews.id", - ) - interview: List[Interviews] = Relationship( - back_populates="inbox", - sa_relationship_kwargs={"lazy": "selectin"} - ) + favorite: Optional[bool] = Field(default=False) + rating: Optional[float] = Field(default=0.0) - activity_id: Optional[uuid.UUID] = Field( - default=None, - foreign_key="activity.id" - ) - activity: Optional[Activity] = Relationship( + # 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"}, ) - Optional[Users] = Relationship( + 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"}, ) @@ -86,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) @@ -126,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" 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..c4ef5e6 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(): @@ -115,11 +180,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 5312f26..4503977 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -1,67 +1,258 @@ -from time import timezone -from token import OP -from sqlmodel import SQLModel, Field -from uuid import UUID, uuid4 -from datetime import datetime -from enum import Enum import uuid -from typing import Optional,List -from sqlmodel import Relationship -from typing import TYPE_CHECKING +from datetime import datetime, timezone +from typing import TYPE_CHECKING, List, Optional + +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 -class Interviews(SQLModel,table=True): + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class Interviews(SQLModel, table=True): __tablename__ = "interviews" - id: UUID = Field(default_factory=uuid4, primary_key=True) - interview_date: datetime = Field(default_factory=datetime.now) - interview_time: datetime = Field(default_factory=datetime.now) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + interview_date: datetime = Field(default_factory=_now) + interview_time: datetime = Field(default_factory=_now) interview_type: str = Field(default="") interview_status: str = Field(default="") - inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") - inbox: Optional[Inbox] = Relationship(back_populates="interview", - sa_relationship_kwargs={"lazy": "joined"} + 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 -class Notes(SQLModel,table=True): + @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 = Field(default_factory=uuid4, primary_key=True) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) note: str = Field(default="") - created_at: datetime = Field(default_factory=datetime.now) - updated_at: datetime = Field(default_factory=datetime.now) - inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") - inbox: Optional[Inbox] = Relationship(back_populates="notes", - sa_relationship_kwargs={"lazy": "joined"} + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + 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]"}, ) - user_id: Optional[uuid.UUID] = Field(default=None, foreign_key="users.id") - user: Optional[Users] = Relationship(back_populates="notes", - sa_relationship_kwargs={"lazy": "joined"} + author: Optional["Users"] = Relationship( + back_populates="authored_notes", + sa_relationship_kwargs={"lazy": "selectin", "foreign_keys": "[Notes.created_by]"}, ) -class Activity(SQLModel,table=True): + @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 = Field(default_factory=uuid4, primary_key=True) + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) activity_type: str = Field(default="") - activity_date: datetime = Field(default_factory=datetime.now) - activity_time: datetime = Field(default_factory=datetime.now) + activity_date: datetime = Field(default_factory=_now) + activity_time: datetime = Field(default_factory=_now) activity_status: str = Field(default="") - inbox_id: Optional[uuid.UUID] = Field(default=None, foreign_key="inbox.id") - inbox: Optional[Inbox] = Relationship(back_populates="activity", - sa_relationship_kwargs={"lazy": "joined"} + 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"}, ) -class Feedback(SQLModel,table=True): + @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 = Field(default_factory=uuid4, primary_key=True) + + 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=Field(default=None) - created_at:datetime=Field(default_factory=datetime.now(timezone.utc)) - updated_at:datetime=Field(default_factory=datetime.now(timezone.utc)) - reviewed_by:Optional[uuid.UUID]=Field(default=None,foreign_key="users.id") - user:Optional[Users]=Relationship(back_populates="feedback", - sa_relationship_kwargs={"lazy":"joined"} - ) \ No newline at end of file + note: str | None = Field(default=None) + created_at: datetime = Field(default_factory=_now) + updated_at: datetime = Field(default_factory=_now) + 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 79b80fd..d097423 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, @@ -30,3 +39,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..07997d1 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,6 +100,26 @@ class CandidateView: except Exception as e: raise HTTPException(status_code=500,detail=str(e)) + 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): """Load full job_posts row and optionally append it onto a candidate payload.""" try: @@ -101,6 +129,11 @@ class CandidateView: payload=serialize_job_post(job_post_data) if isinstance(data,dict): 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)) @@ -117,3 +150,63 @@ class CandidateView: 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=[] + 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 []) + 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 [] + 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/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/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 d578548..d84b1b6 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -7,13 +7,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from sqlmodel import Field, Relationship, SQLModel, select -from backend.job.candidate.models import Feedback from role.models import Roles from job.job_post.models import JobPosts if TYPE_CHECKING: # runtime import would cycle: inbox.models imports this module from inbox.models import Inbox - from job.candidate.models import Notes + from job.candidate.models import Feedback, Notes + class Users(SQLModel, table=True): __tablename__ = "users" @@ -35,11 +35,17 @@ class Users(SQLModel, table=True): back_populates="user", sa_relationship_kwargs={"lazy": "selectin"}, ) - feedback:Optional[Feedback]=Relationship(back_populates="user", - sa_relationship_kwargs={"lazy": "selectin"}, + feedback: List["Feedback"] = Relationship( + back_populates="user", + sa_relationship_kwargs={"lazy": "selectin"}, ) - notes: Optional[Notes] = 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 @@ -143,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 From a81107cb963851d6dbaf6e6658de93fae9ce535a Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 11 Aug 2026 18:19:30 +0500 Subject: [PATCH 4/6] frontend integrated --- backend/job/candidate/models.py | 24 +- frontend/src/api/candidates.js | 62 ++ frontend/src/app/Sidebar.jsx | 4 +- frontend/src/components/BrandMark.jsx | 20 +- frontend/src/screens/CandidateProfile.jsx | 685 ++++++++++++++++++++-- frontend/src/screens/TalentPool.jsx | 17 +- 6 files changed, 758 insertions(+), 54 deletions(-) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 4503977..eeaf849 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -2,6 +2,7 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, List, Optional +from sqlalchemy import DateTime from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import Field, Relationship, SQLModel, select @@ -14,12 +15,19 @@ 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) - interview_time: datetime = Field(default_factory=_now) + 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") @@ -75,8 +83,8 @@ class Notes(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) note: str = Field(default="") - created_at: datetime = Field(default_factory=_now) - updated_at: datetime = Field(default_factory=_now) + 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( @@ -139,8 +147,8 @@ class Activity(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) activity_type: str = Field(default="") - activity_date: datetime = Field(default_factory=_now) - activity_time: datetime = Field(default_factory=_now) + 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") @@ -199,8 +207,8 @@ class Feedback(SQLModel, table=True): financial_status: str = Field(default="") score: float = Field(default=0.0) note: str | None = Field(default=None) - created_at: datetime = Field(default_factory=_now) - updated_at: datetime = Field(default_factory=_now) + 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( 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/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/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/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index cf837a9..9c7fc04 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 +320,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT
))}
- )} + )))} - {tab === 'Interview' && ( + {tab === 'Interview' && (guard || (live ? ( + + ) : ( candidateInterviews.length ? (
{candidateInterviews.map((iv) => ( @@ -167,9 +345,11 @@ export default function CandidateProfile({ candidate: c, onClose, onAdvance, onT Schedule an interview to get started. ) - )} + )))} - {tab === 'Notes' && ( + {tab === 'Notes' && (guard || (live ? ( + + ) : ( <>
@@ -201,9 +381,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 +404,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 +425,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 +456,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 ( + <> +
+ +