candidate inbopx,pipeline progress,cv bank

pull/88/head
ahmed.mujtaba 2026-09-10 16:25:32 +05:00
parent 493120d906
commit de4d360e06
49 changed files with 1884 additions and 329 deletions

View File

@ -17,7 +17,7 @@ from __future__ import annotations
import re import re
from functools import wraps from functools import wraps
from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_PHONE from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_NAME,NO_PHONE
from global_cities import CITY_BY_KEY,CITY_RE from global_cities import CITY_BY_KEY,CITY_RE
_CITY_SENTINELS=frozenset({ _CITY_SENTINELS=frozenset({
@ -191,6 +191,20 @@ def _clean_skills(value,resume_text):
return kept[:30] return kept[:30]
def _clean_name(value,resume_text):
"""Full name from the resume header. Invented / email-shaped values drop."""
text=(value or "").strip()
if not text or text.lower() in {NO_NAME.lower(),"none","null","n/a","-"}:
return ""
if "@" in text or len(text)>120:
return ""
haystack=(resume_text or "").lower()
first=text.split()[0].lower()
if haystack and first not in haystack:
return ""
return text
def _clean_years(value,resume_text): def _clean_years(value,resume_text):
"""Whole years of experience, bounded 0-60. Anything else is None. """Whole years of experience, bounded 0-60. Anything else is None.
@ -230,6 +244,7 @@ clamp_phone=clamp_field("phone",_clean_phone)
clamp_skills=clamp_field("skills",_clean_skills) clamp_skills=clamp_field("skills",_clean_skills)
clamp_years_experience=clamp_field("years_experience",_clean_years) clamp_years_experience=clamp_field("years_experience",_clean_years)
clamp_city=clamp_field("city",_clean_city) clamp_city=clamp_field("city",_clean_city)
clamp_candidate_name=clamp_field("candidate_name",_clean_name)
@require_json_object @require_json_object
@ -241,18 +256,19 @@ clamp_city=clamp_field("city",_clean_city)
@clamp_skills @clamp_skills
@clamp_years_experience @clamp_years_experience
@clamp_city @clamp_city
@clamp_candidate_name
def parse_employment_response(data,resume_text=""): def parse_employment_response(data,resume_text=""):
"""Pull company, education, title, linkedin_url, phone, city, skills, and years """Pull name, company, education, title, linkedin_url, phone, city, skills, and years
from the agent JSON. from the agent JSON.
skills and years_experience default to []/None when the key is absent, so a skills, years_experience, and candidate_name default to []/None/"" when the
model reply predating the extended prompt still parses the inbox match key is absent, so a model reply predating the extended prompt still parses.
path reads the other five keys and must not break on a partial response.
""" """
def as_str(key): def as_str(key):
value=data.get(key) value=data.get(key)
return value.strip() if isinstance(value,str) else "" return value.strip() if isinstance(value,str) else ""
return { return {
"candidate_name":as_str("candidate_name"),
"current_employment":as_str("current_employment"), "current_employment":as_str("current_employment"),
"education":as_str("education"), "education":as_str("education"),
"current_title":as_str("current_title"), "current_title":as_str("current_title"),

View File

@ -19,6 +19,7 @@ async def run_employment_agent(*,resume_text=""):
text=(resume_text or "").strip() text=(resume_text or "").strip()
if not text: if not text:
return { return {
"candidate_name":"",
"current_employment":NO_COMPANY, "current_employment":NO_COMPANY,
"education":EDUCATION, "education":EDUCATION,
"current_title":CURRENT_TITLE, "current_title":CURRENT_TITLE,

View File

@ -15,6 +15,7 @@ CURRENT_TITLE="No JOB POSITION MENTIONED"
NO_LINKEDIN="no linkedin url mentioned" NO_LINKEDIN="no linkedin url mentioned"
NO_PHONE="no phone number mentioned" NO_PHONE="no phone number mentioned"
NO_CITY="no city mentioned" NO_CITY="no city mentioned"
NO_NAME="no name mentioned"
CITY_POLICY="""- Return ONE proper city name only — the city, not an area, town, sector, housing society, cantonment, district, or parenthetical locality. CITY_POLICY="""- Return ONE proper city name only — the city, not an area, town, sector, housing society, cantonment, district, or parenthetical locality.
- Identify the city if possible. Map it to exactly one city name from the countrycities list supplied below. Pakistan is in that list along with every other country do not prefer one country. - Identify the city if possible. Map it to exactly one city name from the countrycities list supplied below. Pakistan is in that list along with every other country do not prefer one country.
@ -29,12 +30,13 @@ CITY_POLICY="""- Return ONE proper city name only — the city, not an area, tow
def prompt(): def prompt():
return f"""You are an HR-ATS recruiting assistant. return f"""You are an HR-ATS recruiting assistant.
You are given CV/resume text. Identify the candidate's CURRENT employer company You are given CV/resume text. Identify the candidate's full name, CURRENT employer company
name, their education (degree / school), their current job title, their name, their education (degree / school), their current job title, their
LinkedIn profile URL, their phone number, their city of residence, their skills, LinkedIn profile URL, their phone number, their city of residence, their skills,
and their total years of professional experience, when present. and their total years of professional experience, when present.
Rules: Rules:
- Return only the candidate name that appears in the resume header.
- Return only the company name that appears in the resume text for the ongoing / most recent role. - 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. - Return only education that appears in the resume text.
- Return only job title that appears in the resume text. - Return only job title that appears in the resume text.
@ -45,6 +47,11 @@ Rules:
- Do not invent education. If none is mentioned, return exactly: {EDUCATION} - Do not invent education. If none is mentioned, return exactly: {EDUCATION}
- Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE} - Do not invent job title. If none is mentioned, return exactly: {CURRENT_TITLE}
candidate_name (its own key a string or the no-name sentinel):
- The candidate's full name exactly as written on the resume header / contact block.
- Do not invent a name from the email local-part, file name, or LinkedIn slug.
- If none is stated, return exactly: {NO_NAME}
skills (its own key a JSON array of strings): skills (its own key a JSON array of strings):
- List the candidate's concrete technical and professional skills: technologies, tools, languages, platforms, and named methodologies. - List the candidate's concrete technical and professional skills: technologies, tools, languages, platforms, and named methodologies.
- Write each skill using the resume's own spelling. Every skill you return MUST appear in the resume text. - Write each skill using the resume's own spelling. Every skill you return MUST appear in the resume text.
@ -91,6 +98,7 @@ Example 1 — local 11-digit PK mobile, full LinkedIn:
Resume: "Ali Khan | Karachi | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience" Resume: "Ali Khan | Karachi | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience"
JSON: JSON:
{{ {{
"candidate_name": "Ali Khan",
"current_employment": "Acme", "current_employment": "Acme",
"education": "BS CS", "education": "BS CS",
"current_title": "Engineer", "current_title": "Engineer",
@ -146,6 +154,7 @@ JSON city must be "Karachi" (one city). Not "Karachi(Malir) Wah Cantt" and not "
Respond with JSON only: Respond with JSON only:
{{ {{
"candidate_name": "Full Name",
"current_employment": "Company Name", "current_employment": "Company Name",
"education": "Degree / School", "education": "Degree / School",
"current_title": "Job Title", "current_title": "Job Title",

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends,HTTPException,Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
import uuid
from db_setup import get_session from db_setup import get_session
from g_sheet.views import ( from g_sheet.views import (
@ -25,6 +26,21 @@ def _city_values(city: str | None):
return parts or None return parts or None
def _job_ids(value: str | None):
if not value or not str(value).strip():
return None
out=[]
for part in str(value).split(","):
text=part.strip()
if not text:
continue
try:
out.append(uuid.UUID(text))
except ValueError:
continue
return out or None
class AppendRowsBody(BaseModel): class AppendRowsBody(BaseModel):
rows: list[list[str]] rows: list[list[str]]
@ -209,6 +225,8 @@ async def fetch_form_data(
source: str | None = Query(None), source: str | None = Query(None),
assigned: bool | None = Query(None), assigned: bool | None = Query(None),
no_suggestions: bool | None = Query(None), no_suggestions: bool | None = Query(None),
has_suggestions: bool | None = Query(None),
job_post_ids: str | None = Query(None),
offset: int = Query(0,ge=0), offset: int = Query(0,ge=0),
limit: int | None = Query(None,ge=1,le=500), limit: int | None = Query(None,ge=1,le=500),
current_user: dict = Depends(_FORM_DATA_READ), current_user: dict = Depends(_FORM_DATA_READ),
@ -222,6 +240,7 @@ async def fetch_form_data(
has_linkedin=has_linkedin,has_resume=has_resume, has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city),source=(source or "").strip() or None, city=_city_values(city),source=(source or "").strip() or None,
assigned=assigned,no_suggestions=no_suggestions, assigned=assigned,no_suggestions=no_suggestions,
has_suggestions=has_suggestions,job_post_ids=_job_ids(job_post_ids),
) )
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -243,6 +262,7 @@ async def fetch_form_data_counts(
city: str | None = Query(None), city: str | None = Query(None),
source: str | None = Query(None), source: str | None = Query(None),
assigned: bool | None = Query(None), assigned: bool | None = Query(None),
job_post_ids: str | None = Query(None),
current_user: dict = Depends(_FORM_DATA_READ), current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -251,6 +271,7 @@ async def fetch_form_data_counts(
data=await service.get_counts( data=await service.get_counts(
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume, sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city),source=(source or "").strip() or None,assigned=assigned, city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
job_post_ids=_job_ids(job_post_ids),
) )
return JSONResponse(content={"data":data,"total":1,"status_code":200}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:

View File

@ -154,10 +154,48 @@ class FormData(SQLModel, table=True):
) )
@classmethod @classmethod
def _talent_pool_filters(cls, *, search=None, job_post_ids=None): def _reapplicant_ids(cls):
"""Form rows from emails that have applied more than once.
Duplicates tab lists flagged duplicates AND every form row from a
repeat email, not only the latest.
"""
ranked = (
select(
cls.id,
cls.reapplied,
func.count().over(
partition_by=func.lower(func.coalesce(cls.candidate_email, "")),
).label("cnt"),
)
.where(func.coalesce(cls.candidate_email, "") != "")
.subquery()
)
reapplied_n = func.coalesce(func.jsonb_array_length(ranked.c.reapplied), 0)
return select(ranked.c.id).where(or_(ranked.c.cnt > 1, reapplied_n > 0))
@classmethod
def _duplicates_tab_filter(cls):
return or_(cls.is_duplicate == True, cls.id.in_(cls._reapplicant_ids())) # noqa: E712
@classmethod
def _talent_pool_filters(cls, *, search=None, job_post_ids=None, assignment=None):
"""Same WHERE as list_for_talent_pool / count_for_talent_pool.""" """Same WHERE as list_for_talent_pool / count_for_talent_pool."""
filters = [cls.manual_upload_candidate_id.is_(None)] filters = [cls.manual_upload_candidate_id.is_(None)]
if job_post_ids is not None: if assignment == "assigned":
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
if job_post_ids is not None:
filters.append(or_(
cls.assigned_job_post_id.in_(list(job_post_ids)),
cls.job_post_id.in_(list(job_post_ids)),
))
elif assignment == "unassigned":
filters.append(cls.assigned_job_post_id.is_(None))
filters.append(cls.job_post_id.is_(None))
filters.append(~cls._no_suggested_jobs())
if job_post_ids is not None:
filters.append(cls._suggested_contains_any(list(job_post_ids)))
elif job_post_ids is not None:
filters.append(cls._matches_any_job(list(job_post_ids))) filters.append(cls._matches_any_job(list(job_post_ids)))
else: else:
filters.append(cls._has_job_link()) filters.append(cls._has_job_link())
@ -167,13 +205,13 @@ class FormData(SQLModel, table=True):
return filters return filters
@classmethod @classmethod
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None): async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None, assignment=None):
"""Candidates list: unpromoted form rows with assigned or suggested jobs.""" """Candidates list: unpromoted form rows with assigned or suggested jobs."""
if job_post_ids is not None and not list(job_post_ids): if job_post_ids is not None and not list(job_post_ids):
return [] return []
qry = ( qry = (
select(cls) select(cls)
.where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids)) .where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment))
.order_by(cls.created_at.desc(), cls.id.desc()) .order_by(cls.created_at.desc(), cls.id.desc())
.limit(limit) .limit(limit)
.offset(offset) .offset(offset)
@ -182,11 +220,11 @@ class FormData(SQLModel, table=True):
return list(result.scalars().all()) return list(result.scalars().all())
@classmethod @classmethod
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None): async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None, assignment=None):
if job_post_ids is not None and not list(job_post_ids): if job_post_ids is not None and not list(job_post_ids):
return 0 return 0
qry = select(func.count()).select_from(cls).where( qry = select(func.count()).select_from(cls).where(
*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids) *cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment)
) )
result = await session.execute(qry) result = await session.execute(qry)
return result.scalar_one() return result.scalar_one()
@ -207,7 +245,7 @@ class FormData(SQLModel, table=True):
def _filters( def _filters(
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None, cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None, has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None, no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
): ):
filters = [] filters = []
if sheet: if sheet:
@ -215,7 +253,10 @@ class FormData(SQLModel, table=True):
if processing_state: if processing_state:
filters.append(cls.processing_state == processing_state) filters.append(cls.processing_state == processing_state)
if is_duplicate is not None: if is_duplicate is not None:
filters.append(cls.is_duplicate == bool(is_duplicate)) if is_duplicate:
filters.append(cls._duplicates_tab_filter())
else:
filters.append(cls.is_duplicate == bool(is_duplicate))
if has_linkedin is not None: if has_linkedin is not None:
matches = [cls.profile_link.ilike(p) for p in LINKEDIN_PATTERNS] matches = [cls.profile_link.ilike(p) for p in LINKEDIN_PATTERNS]
if has_linkedin: if has_linkedin:
@ -252,6 +293,10 @@ class FormData(SQLModel, table=True):
filters.append(cls.job_post_id.is_(None)) filters.append(cls.job_post_id.is_(None))
if no_suggestions is True: if no_suggestions is True:
filters.append(cls._no_suggested_jobs()) filters.append(cls._no_suggested_jobs())
elif has_suggestions is True:
filters.append(~cls._no_suggested_jobs())
if job_post_ids:
filters.append(cls._matches_any_job(list(job_post_ids)))
if inbox_filter == "matched": if inbox_filter == "matched":
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None))) filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
elif inbox_filter == "unassigned": elif inbox_filter == "unassigned":
@ -447,7 +492,7 @@ class FormData(SQLModel, table=True):
cls, session: AsyncSession, *, sheet=None, search=None, cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None, processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, city=None, source=None, assigned=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None, no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
offset=0, limit=None, offset=0, limit=None,
): ):
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc()) statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
@ -457,6 +502,7 @@ class FormData(SQLModel, table=True):
has_linkedin=has_linkedin, has_resume=has_resume, has_linkedin=has_linkedin, has_resume=has_resume,
city=city, source=source, assigned=assigned, city=city, source=source, assigned=assigned,
no_suggestions=no_suggestions, inbox_filter=inbox_filter, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
): ):
statement = statement.where(clause) statement = statement.where(clause)
if offset: if offset:
@ -617,7 +663,7 @@ class FormData(SQLModel, table=True):
cls, session: AsyncSession, *, sheet=None, search=None, cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None, processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, city=None, source=None, assigned=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None, no_suggestions=None, inbox_filter=None, has_suggestions=None, job_post_ids=None,
): ):
statement = select(func.count()).select_from(cls) statement = select(func.count()).select_from(cls)
for clause in cls._filters( for clause in cls._filters(
@ -626,6 +672,7 @@ class FormData(SQLModel, table=True):
has_linkedin=has_linkedin, has_resume=has_resume, has_linkedin=has_linkedin, has_resume=has_resume,
city=city, source=source, assigned=assigned, city=city, source=source, assigned=assigned,
no_suggestions=no_suggestions, inbox_filter=inbox_filter, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
): ):
statement = statement.where(clause) statement = statement.where(clause)
result = await session.execute(statement) result = await session.execute(statement)
@ -635,6 +682,7 @@ class FormData(SQLModel, table=True):
async def count_processing( async def count_processing(
cls, session: AsyncSession, *, sheet=None, search=None, cls, session: AsyncSession, *, sheet=None, search=None,
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None, has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
job_post_ids=None,
): ):
"""Tab badge counts for the Sheet Forms channel. """Tab badge counts for the Sheet Forms channel.
@ -654,13 +702,14 @@ class FormData(SQLModel, table=True):
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 func.coalesce(func.sum(case((cls._duplicates_tab_filter(), 1), else_=0)), 0).label("duplicates"),
func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"), func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
func.coalesce(func.sum(case((~cls._no_suggested_jobs(), 1), else_=0)), 0).label("suggested"),
).select_from(cls) ).select_from(cls)
for clause in cls._filters( for clause in cls._filters(
sheet=sheet, search=search, sheet=sheet, search=search,
has_linkedin=has_linkedin, has_resume=has_resume, city=city, has_linkedin=has_linkedin, has_resume=has_resume, city=city,
source=source, assigned=assigned, source=source, assigned=assigned, job_post_ids=job_post_ids,
): ):
statement = statement.where(clause) statement = statement.where(clause)
row = (await session.execute(statement)).one() row = (await session.execute(statement)).one()
@ -672,6 +721,7 @@ class FormData(SQLModel, table=True):
"rejected": int(row.rejected or 0), "rejected": int(row.rejected or 0),
"duplicates": int(row.duplicates or 0), "duplicates": int(row.duplicates or 0),
"on_hold": int(row.on_hold or 0), "on_hold": int(row.on_hold or 0),
"suggested": int(row.suggested or 0),
} }
@classmethod @classmethod

View File

@ -437,6 +437,7 @@ class SheetFormData(Sheet):
self,sheet=None,search=None,offset=0,limit=None, self,sheet=None,search=None,offset=0,limit=None,
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None, processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
city=None,source=None,assigned=None,no_suggestions=None, city=None,source=None,assigned=None,no_suggestions=None,
has_suggestions=None,job_post_ids=None,
): ):
session=self._require_session() session=self._require_session()
rows=await FormData.fetch_form_data( rows=await FormData.fetch_form_data(
@ -444,12 +445,14 @@ class SheetFormData(Sheet):
processing_state=processing_state,is_duplicate=is_duplicate, processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,city=city, has_linkedin=has_linkedin,has_resume=has_resume,city=city,
source=source,assigned=assigned,no_suggestions=no_suggestions, source=source,assigned=assigned,no_suggestions=no_suggestions,
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
) )
total=await FormData.count_form_data( total=await FormData.count_form_data(
session,sheet=sheet,search=search, session,sheet=sheet,search=search,
processing_state=processing_state,is_duplicate=is_duplicate, processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,city=city, has_linkedin=has_linkedin,has_resume=has_resume,city=city,
source=source,assigned=assigned,no_suggestions=no_suggestions, source=source,assigned=assigned,no_suggestions=no_suggestions,
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
) )
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows]) items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
from job.candidate.views import CandidateView from job.candidate.views import CandidateView
@ -600,11 +603,11 @@ class SheetFormData(Sheet):
raise HTTPException(status_code=404,detail="Form data not found") raise HTTPException(status_code=404,detail="Form data not found")
return await self.get_form_data_by_id(record_id) return await self.get_form_data_by_id(record_id)
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None): async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None,job_post_ids=None):
return await FormData.count_processing( return await FormData.count_processing(
self._require_session(),sheet=sheet,search=search, self._require_session(),sheet=sheet,search=search,
has_linkedin=has_linkedin,has_resume=has_resume,city=city, has_linkedin=has_linkedin,has_resume=has_resume,city=city,
source=source,assigned=assigned, source=source,assigned=assigned,job_post_ids=job_post_ids,
) )
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None): async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):

View File

@ -1,5 +1,6 @@
import hmac import hmac
import os import os
import uuid
from typing import Annotated from typing import Annotated
from fastapi import APIRouter,Depends, Query from fastapi import APIRouter,Depends, Query
@ -26,6 +27,21 @@ def _city_values(city: str | None):
return parts or None return parts or None
def _job_ids(raw: str | None):
if not raw or not str(raw).strip():
return None
out=[]
for part in str(raw).split(","):
text=part.strip()
if not text:
continue
try:
out.append(uuid.UUID(text))
except ValueError:
continue
return out or None
def _apps_payload(items,total,cities=None,sources=None): def _apps_payload(items,total,cities=None,sources=None):
body={"data":items,"total":total,"status_code":200} body={"data":items,"total":total,"status_code":200}
if cities is not None: if cities is not None:
@ -66,6 +82,10 @@ class AssignJobPostBody(BaseModel):
job_post_id: str | None = None job_post_id: str | None = None
class AssignRecruiterBody(BaseModel):
recruiter_id: str | None = None
class ProcessingStateBody(BaseModel): class ProcessingStateBody(BaseModel):
processing_state: str processing_state: str
@ -103,9 +123,11 @@ class ReadAllBody(BaseModel):
assigned: bool | None = None assigned: bool | None = None
is_duplicate: bool | None = None is_duplicate: bool | None = None
no_suggestions: bool | None = None no_suggestions: bool | None = None
has_suggestions: bool | None = None
processing_state: str | None = None processing_state: str | None = None
city: str | None = None city: str | None = None
source: str | None = None source: str | None = None
job_post_ids: str | None = None
class TriageOverrideBody(BaseModel): class TriageOverrideBody(BaseModel):
@ -257,6 +279,23 @@ async def assign_job_post(
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@router.patch("/inbox/{record_id}/assign-recruiter")
async def assign_recruiter(
record_id: str,
payload: AssignRecruiterBody,
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
session: AsyncSession = Depends(get_session),
):
try:
service=Email(session=session)
data=await service.assign_recruiter(record_id,payload.recruiter_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") @router.post("/inbox/{record_id}/read")
async def mark_inbox_read( async def mark_inbox_read(
record_id: str, record_id: str,
@ -314,6 +353,8 @@ async def mark_all_inbox_read(
processing_state=payload.processing_state, processing_state=payload.processing_state,
city=_city_values(payload.city), city=_city_values(payload.city),
source=(payload.source or "").strip() or None, source=(payload.source or "").strip() or None,
has_suggestions=payload.has_suggestions,
job_post_ids=_job_ids(payload.job_post_ids),
) )
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException: except HTTPException:
@ -346,10 +387,12 @@ async def get_all_applications(
assigned: bool | None = Query(default=None), assigned: bool | None = Query(default=None),
is_duplicate: bool | None = Query(default=None), is_duplicate: bool | None = Query(default=None),
no_suggestions: bool | None = Query(default=None), no_suggestions: bool | None = Query(default=None),
has_suggestions: bool | None = Query(default=None),
processing_state: str | None = Query(default=None), processing_state: str | None = Query(default=None),
search: str | None = Query(None), search: str | None = Query(None),
city: str | None = Query(None), city: str | None = Query(None),
source: str | None = Query(None), source: str | None = Query(None),
job_post_ids: str | None = Query(None),
city_list: bool = Query(default=False), city_list: bool = Query(default=False),
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged. # Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
top: int | None = Query(None, ge=1, le=500), top: int | None = Query(None, ge=1, le=500),
@ -361,23 +404,25 @@ async def get_all_applications(
service=Email(session=session) service=Email(session=session)
city_values=_city_values(city) city_values=_city_values(city)
source_value=(source or "").strip() or None source_value=(source or "").strip() or None
job_ids=_job_ids(job_post_ids)
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_ids)
cities=await service.list_cities() if city_list else None cities=await service.list_cities() if city_list else None
sources=await service.list_sources() if city_list else None sources=await service.list_sources() if city_list else 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) or processing_state: 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) or processing_state:
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
return _apps_payload(items,total,cities,sources) return _apps_payload(items,total,cities,sources)
if isread==False: if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value) total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value, **extra)
return _apps_payload(items,total,cities,sources) return _apps_payload(items,total,cities,sources)
if record_id: if record_id:
item=await service.get_application_by_id(record_id) item=await service.get_application_by_id(record_id)
return _apps_payload(item,1,cities,sources) return _apps_payload(item,1,cities,sources)
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value) items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value,**extra)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value) total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value,**extra)
return _apps_payload(items,total,cities,sources) return _apps_payload(items,total,cities,sources)
except HTTPException: except HTTPException:
raise raise

View File

@ -1,5 +1,6 @@
import logging import logging
import os import os
import re
from shlex import join from shlex import join
import uuid import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
@ -9,7 +10,7 @@ from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status from inbox.enums import Candidate_application_Status
from role.models import EnumRoles, Roles from role.models import EnumRoles, Roles
from sqlalchemy import Column, DateTime, case, false, func, or_, update from sqlalchemy import Column, DateTime, and_, case, false, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -36,6 +37,20 @@ def _now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _years_from_text(value):
"""Whole years from ATS int or inbox free-text ('5+', '5 years'). Else None."""
if value is None or isinstance(value,bool):
return None
if isinstance(value,(int,float)):
years=int(value)
return years if 0<=years<=60 else None
digits=re.search(r"\d+",str(value))
if not digits:
return None
years=int(digits.group())
return years if 0<=years<=60 else None
class Inbox(SQLModel, table=True): class Inbox(SQLModel, table=True):
__tablename__ = "inbox" __tablename__ = "inbox"
@ -74,7 +89,7 @@ class Inbox(SQLModel, table=True):
) )
@classmethod @classmethod
async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0): async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0,search=None):
try: try:
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
qry=( qry=(
@ -124,6 +139,9 @@ class Inbox(SQLModel, table=True):
qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids)) qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids))
elif job_post_id: elif job_post_id:
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id) qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
if search and str(search).strip():
like=f"%{str(search).strip()}%"
qry=qry.where(or_(Users.name.ilike(like),Users.email.ilike(like)))
if limit is not None: if limit is not None:
qry=qry.limit(limit).offset(offset) qry=qry.limit(limit).offset(offset)
result=await session.execute(qry) result=await session.execute(qry)
@ -261,6 +279,8 @@ class Inbox(SQLModel, table=True):
Inbox_Messages.current_employment.label("current_company"), Inbox_Messages.current_employment.label("current_company"),
Inbox_Messages.current_title, Inbox_Messages.current_title,
Inbox_Messages.candidate_education.label("education"), Inbox_Messages.candidate_education.label("education"),
Inbox_Messages.city,
Inbox_Messages.experience.label("inbox_experience"),
cls.message_id.label("message_id"), cls.message_id.label("message_id"),
Inbox_Messages.file_name, Inbox_Messages.file_name,
Inbox_Messages.file_path, Inbox_Messages.file_path,
@ -302,6 +322,7 @@ class Inbox(SQLModel, table=True):
"current_company":row["current_company"] or None, "current_company":row["current_company"] or None,
"current_title":row["current_title"] or None, "current_title":row["current_title"] or None,
"education":row["education"] or None, "education":row["education"] or None,
"city":row["city"] or None,
"file_name":row["file_name"] or None, "file_name":row["file_name"] or None,
"file_path":row["file_path"] or None, "file_path":row["file_path"] or None,
"ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None, "ai_score":int(row["ats_score"]) if row["ats_score"] is not None else None,
@ -312,7 +333,7 @@ class Inbox(SQLModel, table=True):
"last_job_post_id":str(row["last_job_post_id"]) if row["last_job_post_id"] else None, "last_job_post_id":str(row["last_job_post_id"]) if row["last_job_post_id"] else None,
"last_job_title":row["last_job_title"] or None, "last_job_title":row["last_job_title"] or None,
"matched_keywords":list(row["matched_keywords"] or []), "matched_keywords":list(row["matched_keywords"] or []),
"years_experience":row["years_experience"], "years_experience":row["years_experience"] if row["years_experience"] is not None else _years_from_text(row["inbox_experience"]),
"bank_expires_at":None, "bank_expires_at":None,
"created_at":row["created_at"], "created_at":row["created_at"],
}) })
@ -340,7 +361,7 @@ class Inbox(SQLModel, table=True):
return out return out
@classmethod @classmethod
async def count_by_status(cls,session:AsyncSession,job_post_id=None): async def count_by_status(cls,session:AsyncSession,job_post_id=None,search=None):
try: try:
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
qry=( qry=(
@ -357,6 +378,9 @@ class Inbox(SQLModel, table=True):
) )
if job_post_id: if job_post_id:
qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id) qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id)
if search and str(search).strip():
like=f"%{str(search).strip()}%"
qry=qry.where(or_(Users.name.ilike(like),Users.email.ilike(like)))
result=await session.execute(qry) result=await session.execute(qry)
counts={} counts={}
for status,n in result.all(): for status,n in result.all():
@ -374,21 +398,37 @@ class Inbox(SQLModel, table=True):
return or_(Users.name.ilike(pattern), Users.email.ilike(pattern)) return or_(Users.name.ilike(pattern), Users.email.ilike(pattern))
@classmethod @classmethod
def _with_job_link(cls, qry, job_post_ids): def _with_job_link(cls, qry, job_post_ids, assignment=None):
"""List mode: keep only applications with an assigned post or suggestions. """List mode: keep only applications with an assigned post or suggestions.
`job_post_ids is None` is the unscoped (admin) list still require a `job_post_ids is None` is the unscoped (admin) list still require a
link so unassigned inbox mail never appears on Candidates. A UUID list link so unassigned inbox mail never appears on Candidates. A UUID list
is assigned IN those ids OR suggested_job_post_ids containing any of is assigned IN those ids OR suggested_job_post_ids containing any of
them (the `assigned_job_post_id` query param is that one-element list). them (the `assigned_job_post_id` query param is that one-element list).
`assignment` is assigned | unassigned | None. Assigned means a real
assigned_job_post_id. Unassigned means suggestions only no assigned post.
""" """
qry = qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id) qry = qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
if assignment == "assigned":
qry = qry.where(Inbox_Messages.assigned_job_post_id.is_not(None))
if job_post_ids is not None:
return qry.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
return qry
if assignment == "unassigned":
qry = qry.where(
Inbox_Messages.assigned_job_post_id.is_(None),
~Inbox_Messages._no_suggested_jobs(),
)
if job_post_ids is not None:
return qry.where(Inbox_Messages._suggested_contains_any(list(job_post_ids)))
return qry
if job_post_ids is not None: if job_post_ids is not None:
return qry.where(Inbox_Messages._matches_any_job(list(job_post_ids))) return qry.where(Inbox_Messages._matches_any_job(list(job_post_ids)))
return qry.where(Inbox_Messages._has_job_link()) return qry.where(Inbox_Messages._has_job_link())
@classmethod @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,job_post_ids=None): async def get_candidate_profile(cls,session:AsyncSession,user_id:uuid.UUID|None=None,limit:int=10,offset:int=0,search:str|None=None,job_post_ids=None,assignment=None):
try: try:
if job_post_ids is not None and not list(job_post_ids): if job_post_ids is not None and not list(job_post_ids):
return [] return []
@ -411,7 +451,7 @@ class Inbox(SQLModel, table=True):
if search: if search:
qry = qry.where(cls._candidate_search_filter(search)) qry = qry.where(cls._candidate_search_filter(search))
if not user_id: if not user_id:
qry = cls._with_job_link(qry, job_post_ids) qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
# Most-recent-first is the list contract; id breaks ties so a page # Most-recent-first is the list contract; id breaks ties so a page
# boundary can't drop or repeat a row when created_at collides. # boundary can't drop or repeat a row when created_at collides.
qry = qry.order_by(cls.created_at.desc(), cls.id.desc()) qry = qry.order_by(cls.created_at.desc(), cls.id.desc())
@ -425,7 +465,7 @@ class Inbox(SQLModel, table=True):
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@classmethod @classmethod
async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None): async def count_candidate_profiles(cls,session:AsyncSession,user_id:uuid.UUID|None=None,search:str|None=None,job_post_ids=None,assignment=None):
"""Result-set size for the same predicate get_candidate_profile pages over.""" """Result-set size for the same predicate get_candidate_profile pages over."""
try: try:
if job_post_ids is not None and not list(job_post_ids): if job_post_ids is not None and not list(job_post_ids):
@ -442,7 +482,7 @@ class Inbox(SQLModel, table=True):
if search: if search:
qry = qry.where(cls._candidate_search_filter(search)) qry = qry.where(cls._candidate_search_filter(search))
if not user_id: if not user_id:
qry = cls._with_job_link(qry, job_post_ids) qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
result = await session.execute(qry) result = await session.execute(qry)
return result.scalar_one() return result.scalar_one()
except Exception as e: except Exception as e:
@ -491,7 +531,7 @@ class Inbox(SQLModel, table=True):
rid = None rid = None
if rid is not None: if rid is not None:
statement = statement.where( statement = statement.where(
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
) )
return statement return statement
@ -1064,6 +1104,34 @@ class Inbox_Messages(SQLModel, table=True):
cls._suggested_contains_any(ids), cls._suggested_contains_any(ids),
) )
@classmethod
def _latest_reapplicant_ids(cls):
"""Newest inbox application per sender who has applied more than once.
Duplicates tab lists flagged duplicates AND this latest row older
reapplicant mail stays on All Applications.
"""
ranked = (
select(
cls.id,
func.row_number().over(
partition_by=func.lower(cls.message_from),
order_by=(cls.created_at.desc(), cls.id.desc()),
).label("rn"),
func.count().over(partition_by=func.lower(cls.message_from)).label("cnt"),
)
.where(cls.attachment == True) # noqa: E712
.subquery()
)
return select(ranked.c.id).where(
ranked.c.rn == 1,
ranked.c.cnt > 1,
)
@classmethod
def _duplicates_tab_filter(cls):
return or_(cls.is_duplicate == True, cls.id.in_(cls._latest_reapplicant_ids())) # noqa: E712
@staticmethod @staticmethod
def _cities_match(column, cities): def _cities_match(column, cities):
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir).""" """Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
@ -1087,6 +1155,8 @@ class Inbox_Messages(SQLModel, table=True):
city=None, city=None,
source: str | None=None, source: str | None=None,
inbox_filter: str | None=None, inbox_filter: str | None=None,
has_suggestions: bool | None=None,
job_post_ids=None,
): ):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE. """The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -1106,11 +1176,15 @@ class Inbox_Messages(SQLModel, table=True):
if isread==False: if isread==False:
statement = statement.where(cls.message_read==False) statement = statement.where(cls.message_read==False)
if is_duplicate is True: if is_duplicate is True:
statement = statement.where(cls.is_duplicate==True) # noqa: E712 statement = statement.where(cls._duplicates_tab_filter())
elif is_duplicate is False: elif is_duplicate is False:
statement = statement.where(cls.is_duplicate==False) # noqa: E712 statement = statement.where(cls.is_duplicate==False) # noqa: E712
if no_suggestions is True: if no_suggestions is True:
statement = statement.where(cls._no_suggested_jobs()) statement = statement.where(cls._no_suggested_jobs())
elif has_suggestions is True:
statement = statement.where(~cls._no_suggested_jobs())
if job_post_ids:
statement = statement.where(cls._matches_any_job(job_post_ids))
if processing_state: if processing_state:
statement = statement.where(cls.processing_state == processing_state) statement = statement.where(cls.processing_state == processing_state)
cities = [c.strip() for c in (city or []) if (c or "").strip()] cities = [c.strip() for c in (city or []) if (c or "").strip()]
@ -1150,12 +1224,13 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod @classmethod
async def get_inbox_messages( 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, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False 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, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False, has_suggestions: bool | None=None, job_post_ids=None,
): ):
statement = cls._apply_filters( statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc(), cls.id.desc()), select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, city, source, no_suggestions, processing_state, city, source,
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
) )
if skip: if skip:
statement = statement.offset(skip) statement = statement.offset(skip)
@ -1248,6 +1323,24 @@ class Inbox_Messages(SQLModel, table=True):
await session.refresh(row) await session.refresh(row)
return row return row
@classmethod
async def set_recruiter(cls, session: AsyncSession, record_id, recruiter_id):
"""Set or clear recruiter_id on one inbox row."""
row = await cls.get_inbox_message_by_id(session, record_id)
if not row:
return None
if recruiter_id is None:
row.recruiter_id = None
else:
try:
row.recruiter_id = uuid.UUID(str(recruiter_id))
except ValueError:
return None
session.add(row)
await session.commit()
await session.refresh(row)
return row
@classmethod @classmethod
async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]: async def counts_by_job_post_ids(cls, session: AsyncSession, job_post_ids) -> dict[str, int]:
"""Resolve {job_post_id: applicant_count} for a page of rows in a single query. """Resolve {job_post_id: applicant_count} for a page of rows in a single query.
@ -1266,11 +1359,12 @@ class Inbox_Messages(SQLModel, table=True):
return {str(job_id): int(n) for job_id, n in result.all()} return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod @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, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None): 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, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, has_suggestions: bool | None=None, job_post_ids=None):
statement = cls._apply_filters( statement = cls._apply_filters(
select(func.count()).select_from(cls), select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, city, source, no_suggestions, processing_state, city, source,
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
) )
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@ -1389,6 +1483,8 @@ class Inbox_Messages(SQLModel, table=True):
no_suggestions: bool | None=None, no_suggestions: bool | None=None,
city=None, city=None,
source: str | None=None, source: str | None=None,
has_suggestions: bool | None=None,
job_post_ids=None,
) -> int: ) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED. """Mark every row matching a list filter. Returns rows actually CHANGED.
@ -1400,6 +1496,7 @@ class Inbox_Messages(SQLModel, table=True):
statement=cls._apply_filters( statement=cls._apply_filters(
update(cls),search,isread,application_status,assigned,is_duplicate, update(cls),search,isread,application_status,assigned,is_duplicate,
no_suggestions,processing_state,city,source, no_suggestions,processing_state,city,source,
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
) )
statement=statement.where(cls.message_read!=bool(read)) statement=statement.where(cls.message_read!=bool(read))
result=await session.execute( result=await session.execute(
@ -1416,8 +1513,9 @@ class Inbox_Messages(SQLModel, table=True):
func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"), func.coalesce(func.sum(case((cls.processing_state == "imported", 1), else_=0)), 0).label("imported"),
func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"), func.coalesce(func.sum(case((cls.processing_state == "processed", 1), else_=0)), 0).label("processed"),
func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"), func.coalesce(func.sum(case((cls.processing_state == "rejected", 1), else_=0)), 0).label("rejected"),
func.coalesce(func.sum(case((cls.is_duplicate == True, 1), else_=0)), 0).label("duplicates"), # noqa: E712 func.coalesce(func.sum(case((cls._duplicates_tab_filter(), 1), else_=0)), 0).label("duplicates"),
func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"), func.coalesce(func.sum(case((cls._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
func.coalesce(func.sum(case((~cls._no_suggested_jobs(), 1), else_=0)), 0).label("suggested"),
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_not(None), 1), else_=0)), 0).label("assigned"),
func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"), func.coalesce(func.sum(case((cls.assigned_job_post_id.is_(None), 1), else_=0)), 0).label("unassigned"),
).where(cls.attachment == True) # noqa: E712 ).where(cls.attachment == True) # noqa: E712
@ -1430,6 +1528,7 @@ class Inbox_Messages(SQLModel, table=True):
"rejected": int(row.rejected or 0), "rejected": int(row.rejected or 0),
"duplicates": int(row.duplicates or 0), "duplicates": int(row.duplicates or 0),
"on_hold": int(row.on_hold or 0), "on_hold": int(row.on_hold or 0),
"suggested": int(row.suggested or 0),
"assigned": int(row.assigned or 0), "assigned": int(row.assigned or 0),
"unassigned": int(row.unassigned or 0), "unassigned": int(row.unassigned or 0),
} }
@ -1508,7 +1607,7 @@ class Inbox_Messages(SQLModel, table=True):
rid = None rid = None
if rid is not None: if rid is not None:
statement = statement.where( statement = statement.where(
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid) or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
) )
return statement return statement
@ -1575,7 +1674,7 @@ class Inbox_Messages(SQLModel, table=True):
rid = None rid = None
if rid is not None: if rid is not None:
statement = statement.where( statement = statement.where(
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid) or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
) )
statement = statement.group_by(cls.application_status) statement = statement.group_by(cls.application_status)
result = await session.execute(statement) result = await session.execute(statement)
@ -1619,7 +1718,7 @@ class Inbox_Messages(SQLModel, table=True):
rid = None rid = None
if rid is not None: if rid is not None:
statement = statement.where( statement = statement.where(
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid) or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
) )
statement = statement.group_by(cls.assigned_job_post_id) statement = statement.group_by(cls.assigned_job_post_id)
result = await session.execute(statement) result = await session.execute(statement)

View File

@ -2,6 +2,24 @@ from pathlib import Path
from inbox.models import Inbox_Message_Triage, Inbox_Messages from inbox.models import Inbox_Message_Triage, Inbox_Messages
_PHONE_PLACEHOLDER = "xxx-xxx-xxxx"
def _stored_phone(value):
text = (value or "").strip()
if not text or text.lower() == _PHONE_PLACEHOLDER:
return None
return text
def _stored_experience(value):
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
years = int(value)
return str(years)
return str(value).strip()
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render. # match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
_RESUME_STATUS = { _RESUME_STATUS = {
"processing": "Parsing", "processing": "Parsing",
@ -85,6 +103,14 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
"ats_score": message.ats_score, "ats_score": message.ats_score,
"ats_band": message.ats_band or None, "ats_band": message.ats_band or None,
"professional_summary": message.professional_summary or None, "professional_summary": message.professional_summary or None,
"phone": _stored_phone(message.candidate_phone_number),
"experience": _stored_experience(message.experience),
"current_employment": message.current_employment or "",
"current_title": message.current_title or "",
"city": message.city or None,
"education": message.candidate_education or "",
"recruiter_id": str(message.recruiter_id) if message.recruiter_id else None,
"recruiter": None,
} }
@ -154,12 +180,14 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light:
"ats_score": message.ats_score, "ats_score": message.ats_score,
"ats_band": message.ats_band or None, "ats_band": message.ats_band or None,
"professional_summary": message.professional_summary or None, "professional_summary": message.professional_summary or None,
"phone": message.candidate_phone_number, "phone": _stored_phone(message.candidate_phone_number),
"experience": message.experience or "", "experience": _stored_experience(message.experience),
"current_employment": message.current_employment or "", "current_employment": message.current_employment or "",
"current_title": message.current_title or "", "current_title": message.current_title or "",
"city": message.city or None, "city": message.city or None,
"recruiter": str(message.recruiter_id) if message.recruiter_id else None, "education": message.candidate_education or "",
"recruiter_id": str(message.recruiter_id) if message.recruiter_id else None,
"recruiter": None,
"duplicate": message.is_duplicate, "duplicate": message.is_duplicate,
"processing_state": message.processing_state, "processing_state": message.processing_state,
"source_channel_id": message.source_channel_id, "source_channel_id": message.source_channel_id,

View File

@ -213,9 +213,8 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
result=await run_agent(subject=subject,resume_text=text,job_posts=job_posts) result=await run_agent(subject=subject,resume_text=text,job_posts=job_posts)
status=result.get("status") or "failed" status=result.get("status") or "failed"
if status=="failed": # Extract the profile even when matching finds no job — On-Hold / unassigned
raise RuntimeError(result.get("error") or "agent returned failed status") # CVs still need name, title, years, and phone on every screen.
fields=await run_employment_agent( fields=await run_employment_agent(
resume_text=text if not body else f"{text}\n\n{body}", resume_text=text if not body else f"{text}\n\n{body}",
) )
@ -225,14 +224,39 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
linkedin_url=fields["linkedin_url"] linkedin_url=fields["linkedin_url"]
phone=fields["phone"] phone=fields["phone"]
city=fields.get("city") or None city=fields.get("city") or None
years=fields.get("years_experience")
experience=(result.get("experience") or "").strip()
if not experience and years is not None:
experience=str(int(years)) if isinstance(years,(int,float)) and not isinstance(years,bool) else str(years)
if status=="failed":
async with session_scope() as session:
await Inbox_Messages.set_match_result(
session,
record_id,
resume_text=text,
experience=experience,
candidate_phone_number=phone if phone else "",
current_employment=current_employment,
current_title=current_title,
candidate_education=education,
linkedin_url=linkedin_url,
city=city,
suggested_job_post_ids=[],
summary=result.get("summary") or "",
reasoning=result.get("reasoning") or "",
status="failed",
error=result.get("error") or "agent returned failed status",
)
raise RuntimeError(result.get("error") or "agent returned failed status")
async with session_scope() as session: async with session_scope() as session:
await Inbox_Messages.set_match_result( await Inbox_Messages.set_match_result(
session, session,
record_id, record_id,
resume_text=text, resume_text=text,
experience=result.get("experience") or "", experience=experience,
candidate_phone_number=phone, candidate_phone_number=phone if phone else "",
current_employment=current_employment, current_employment=current_employment,
current_title=current_title, current_title=current_title,
candidate_education=education, candidate_education=education,

View File

@ -259,13 +259,14 @@ class Email:
items=await self._attach_job_posts([item]) items=await self._attach_job_posts([item])
return await cv.attach_application_history(items[0]) return await cv.attach_application_history(items[0])
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None): async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None,has_suggestions=None,job_post_ids=None):
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_post_ids,light=True)
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) or processing_state: 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) or processing_state:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
elif isread==False: elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
else: else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages] items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
items=await self._attach_job_posts(items) items=await self._attach_job_posts(items)
@ -310,6 +311,19 @@ class Email:
suggested.append(dict(payload)) suggested.append(dict(payload))
item["suggested_job_posts"]=suggested item["suggested_job_posts"]=suggested
items=await self._paint_inbox_ats(items) items=await self._paint_inbox_ats(items)
items=await self._attach_recruiters(items)
return items
async def _attach_recruiters(self,items):
"""Resolve recruiter_id → display name. Serializer leaves recruiter None."""
ids=[item.get("recruiter_id") for item in items if item.get("recruiter_id")]
names={}
if ids:
from users.models import Users
names=await Users.names_by_ids(self.session,ids)
for item in items:
rid=item.get("recruiter_id")
item["recruiter"]=names.get(str(rid)) if rid else None
return items return items
async def _paint_inbox_ats(self,items): async def _paint_inbox_ats(self,items):
@ -767,13 +781,14 @@ class Email:
results.append({"email":email,"sent":False}) results.append({"email":email,"sent":False})
return results return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None,has_suggestions=None,job_post_ids=None):
extra=dict(has_suggestions=has_suggestions,job_post_ids=job_post_ids)
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state: if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
elif isread==False: elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
else: else:
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source) return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,**extra)
async def list_cities(self): async def list_cities(self):
"""Proper city names for the Inbox filter — DISTINCT of the stored city column.""" """Proper city names for the Inbox filter — DISTINCT of the stored city column."""
@ -820,6 +835,23 @@ class Email:
logger.warning("could not queue ats score for %s: %s",record_id,exc) logger.warning("could not queue ats score for %s: %s",record_id,exc)
return await self.get_inbox_message_by_id(record_id) return await self.get_inbox_message_by_id(record_id)
async def assign_recruiter(self,record_id,recruiter_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 recruiter_id is not None:
from role.models import EnumRoles
from users.models import Users
user=await Users.get_user_by_id(self.session,recruiter_id)
role=getattr(user,"role",None) if user else None
role_name=getattr(role,"role_name",None)
if user is None or role_name != EnumRoles.RECRUITER.value:
raise HTTPException(status_code=422,detail="recruiter_id must be an active recruiter")
updated=await Inbox_Messages.set_recruiter(self.session,record_id,recruiter_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,read=True): async def mark_read(self,record_id,read=True):
message=await Inbox_Messages.mark_message_read(self.session,record_id,read) message=await Inbox_Messages.mark_message_read(self.session,record_id,read)
if not message: if not message:
@ -849,7 +881,7 @@ class Email:
async def set_read_all(self,read,search=None,isread:bool=True, async def set_read_all(self,read,search=None,isread:bool=True,
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None, assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,
city=None,source=None): city=None,source=None,has_suggestions=None,job_post_ids=None):
"""Mark every row the SAME filter set would have listed. """Mark every row the SAME filter set would have listed.
The filter arguments are the caller's current view, not a free-form query: the The filter arguments are the caller's current view, not a free-form query: the
@ -861,6 +893,7 @@ class Email:
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
no_suggestions=no_suggestions,processing_state=processing_state, no_suggestions=no_suggestions,processing_state=processing_state,
city=city,source=source, city=city,source=source,
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
) )
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s", logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate) updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)

View File

@ -153,6 +153,7 @@ class JobUpdate(BaseModel):
experience_max: int | None = None experience_max: int | None = None
description: str | None = None description: str | None = None
current_recruiter_id: UUID | None = None current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None
hiring_manager_id: UUID | None = None hiring_manager_id: UUID | None = None
requisition_id: UUID | None = None requisition_id: UUID | None = None
@ -345,7 +346,7 @@ async def cv_bank_upload(
row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv( row=await Manual_UPLOAD_CANDIDATE.insert_bank_cv(
session, session,
candidate_email=detected or "", candidate_email=detected or "",
candidate_name="", candidate_name=(profile.get("candidate_name") or "").strip(),
full_text=text, full_text=text,
file_name=original, file_name=original,
created_by=current_user.get("id"), created_by=current_user.get("id"),
@ -1024,7 +1025,8 @@ async def fetch_manager_candidates(
async def fetch_candidate( async def fetch_candidate(
user_id:str=Query(None), user_id:str=Query(None),
limit:int=Query(10,ge=1,le=100), limit:int=Query(10,ge=1,le=100),
assigned_job_post_id:UUID=Query(None), assigned_job_post_id:Optional[str]=Query(None),
assignment:Optional[str]=Query(None),
offset:int=Query(0,ge=0), offset:int=Query(0,ge=0),
search:str=Query(None), search:str=Query(None),
created_by:Optional[bool]=Query(False), created_by:Optional[bool]=Query(False),
@ -1032,14 +1034,17 @@ async def fetch_candidate(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
try: try:
assignment_value=(assignment or "").strip().lower() or None
if assignment_value and assignment_value not in ("assigned","unassigned"):
raise HTTPException(status_code=422,detail="assignment must be assigned or unassigned")
service=CandidateView(session=session) service=CandidateView(session=session)
data=await service.get_candidate( data=await service.get_candidate(
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by, user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,assignment=assignment_value,
) )
total=await service.count_candidates( total=await service.count_candidates(
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by, user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,assignment=assignment_value,
) if isinstance(data,list) else 1 ) if isinstance(data,list) else 1
return JSONResponse(content={"data":data,"total":total,"status_code":200}) return JSONResponse(content={"data":data,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -1336,6 +1341,7 @@ async def change_candidate_stage(
@router.get("/pipeline/candidates/fetch") @router.get("/pipeline/candidates/fetch")
async def fetch_pipeline_candidates( async def fetch_pipeline_candidates(
job_post_id:Optional[uuid.UUID]=Query(None), job_post_id:Optional[uuid.UUID]=Query(None),
search:Optional[str]=Query(None),
limit:int=Query(10,ge=1,le=1000), limit:int=Query(10,ge=1,le=1000),
offset:int=Query(0,ge=0), offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
@ -1343,7 +1349,7 @@ async def fetch_pipeline_candidates(
): ):
try: try:
service=Pipeline(session=session) service=Pipeline(session=session)
result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset) result=await service.get_all(job_post_id=job_post_id,limit=limit,offset=offset,search=search)
return JSONResponse(content={**result,"status_code":200}) return JSONResponse(content={**result,"status_code":200})
except HTTPException: except HTTPException:
raise raise

View File

@ -82,6 +82,50 @@ class JobAssignments(SQLModel, table=True):
await session.commit() await session.commit()
return len(rows) return len(rows)
@classmethod
async def sync_open(cls, session: AsyncSession, job_post_id, assignment_role, user_ids, assigned_by):
"""Make open intervals for this role match user_ids (order preserved)."""
uid = cls._as_uuid(job_post_id)
by_uid = cls._as_uuid(assigned_by)
if uid is None or not assignment_role or by_uid is None:
return 0
wanted = []
seen = set()
for raw in user_ids or []:
user_uid = cls._as_uuid(raw)
if user_uid is None:
continue
key = str(user_uid)
if key in seen:
continue
seen.add(key)
wanted.append(user_uid)
current = await cls.fetch_by_job(
session, uid, current_only=True, assignment_role=assignment_role,
)
current_map = {str(r.user_id): r for r in current}
now = _now()
wanted_set = {str(u) for u in wanted}
changed = False
for key, row in current_map.items():
if key not in wanted_set:
row.valid_to = now
session.add(row)
changed = True
for user_uid in wanted:
if str(user_uid) in current_map:
continue
session.add(cls(
job_post_id=uid,
user_id=user_uid,
assignment_role=assignment_role,
assigned_by=by_uid,
))
changed = True
if changed:
await session.commit()
return len(wanted)
@classmethod @classmethod
async def insert_assignment(cls, session: AsyncSession, fields: dict): async def insert_assignment(cls, session: AsyncSession, fields: dict):
row = cls(**fields) row = cls(**fields)

View File

@ -80,6 +80,16 @@ class Assignment:
"assigned_by":by_uid, "assigned_by":by_uid,
}) })
async def record_job_recruiters(self,job_post_id,user_ids,assigned_by):
"""Keep open primary_recruiter intervals in sync with the JSON list."""
job_uid=JobAssignments._as_uuid(job_post_id)
by_uid=JobAssignments._as_uuid(assigned_by)
if not job_uid or not by_uid:
raise HTTPException(status_code=422,detail="Invalid job_post_id or assigned_by")
return await JobAssignments.sync_open(
self.session,job_uid,"primary_recruiter",user_ids,by_uid,
)
async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None): async def list_job_assignments(self,job_post_id,current_only=True,assignment_role=None):
if not job_post_id: if not job_post_id:
raise HTTPException(status_code=400,detail="job_post_id is required") raise HTTPException(status_code=400,detail="job_post_id is required")
@ -105,18 +115,24 @@ class Assignment:
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by) row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
column=JOB_OWNER_COLUMN[role] column=JOB_OWNER_COLUMN[role]
updated=await JobPosts.update_job_post(self.session,job_post_id,{column:user_id}) patch={column:user_id}
if role=="primary_recruiter":
patch["current_recruiter_ids"]=[str(user_id)]
updated=await JobPosts.update_job_post(self.session,job_post_id,patch)
if updated: if updated:
try: try:
from notifications.views import notify_job_assignment from notifications.views import notify_job_assignment
label="hiring manager" if role=="hiring_manager" else "recruiter" label="hiring manager" if role=="hiring_manager" else "recruiter"
previous=(
[job.hiring_manager_id]
if role=="hiring_manager"
else JobPosts.recruiter_ids_of(job)
)
await notify_job_assignment( await notify_job_assignment(
self.session,updated, self.session,updated,
role_label=label, role_label=label,
actor_id=assigned_by, actor_id=assigned_by,
previous_ids=[ previous_ids=previous,
job.hiring_manager_id if role=="hiring_manager" else job.current_recruiter_id
],
) )
except Exception as exc: except Exception as exc:
logger.warning("notification insert skipped: %s", exc) logger.warning("notification insert skipped: %s", exc)

View File

@ -197,19 +197,25 @@ async def _notify_owner(job_post_id: str, count: int) -> None:
job = await JobPosts.get_job_post_by_id(session, job_post_id) job = await JobPosts.get_job_post_by_id(session, job_post_id)
if job is None: if job is None:
return return
raw = getattr(job, "current_recruiter_id", None) or getattr(job, "created_by", None) ids = JobPosts.recruiter_ids_of(job)
if not raw: if not ids:
created = getattr(job, "created_by", None)
if created:
ids = [str(created)]
if not ids:
return return
await Notifications.insert_notification(session, { body = (
"user_id": _uuid.UUID(str(raw)), f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
"kind": "application", f"{job.title}. Open the CV Bank to review them."
"title": "CVs in the bank match this job", )
"body": ( for raw in ids:
f"{count} stored CV{'s' if count != 1 else ''} look relevant to " await Notifications.insert_notification(session, {
f"{job.title}. Open the CV Bank to review them." "user_id": _uuid.UUID(str(raw)),
), "kind": "application",
"link_path": f"/cvbank?job={job_post_id}", "title": "CVs in the bank match this job",
"job_post_id": job.id, "body": body,
}) "link_path": f"/cvbank?job={job_post_id}",
"job_post_id": job.id,
})
except Exception: except Exception:
logger.exception("cv-bank suggestion notification failed job=%s", job_post_id) logger.exception("cv-bank suggestion notification failed job=%s", job_post_id)

View File

@ -76,7 +76,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod @classmethod
async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0): async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0, search=None):
try: try:
from inbox.models import AtsResults from inbox.models import AtsResults
from users.models import Users from users.models import Users
@ -133,6 +133,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
qry=qry.where(cls.job_post_id.in_(ids)) qry=qry.where(cls.job_post_id.in_(ids))
elif job_post_id: elif job_post_id:
qry=qry.where(cls.job_post_id==job_post_id) qry=qry.where(cls.job_post_id==job_post_id)
if search and str(search).strip():
like=f"%{str(search).strip()}%"
qry=qry.where(or_(
Users.name.ilike(like),
Users.email.ilike(like),
cls.candidate_name.ilike(like),
cls.candidate_email.ilike(like),
))
if limit is not None: if limit is not None:
qry=qry.limit(limit).offset(offset) qry=qry.limit(limit).offset(offset)
result=await session.execute(qry) result=await session.execute(qry)
@ -174,7 +182,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@classmethod @classmethod
async def count_by_status(cls, session: AsyncSession, job_post_id=None): async def count_by_status(cls, session: AsyncSession, job_post_id=None, search=None):
try: try:
from users.models import Users from users.models import Users
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
@ -187,6 +195,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
) )
if job_post_id: if job_post_id:
qry=qry.where(cls.job_post_id==job_post_id) qry=qry.where(cls.job_post_id==job_post_id)
if search and str(search).strip():
like=f"%{str(search).strip()}%"
qry=qry.where(or_(
Users.name.ilike(like),
Users.email.ilike(like),
cls.candidate_name.ilike(like),
cls.candidate_email.ilike(like),
))
result=await session.execute(qry) result=await session.execute(qry)
counts={} counts={}
for status,n in result.all(): for status,n in result.all():
@ -225,7 +241,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
except (TypeError,ValueError): except (TypeError,ValueError):
rid=None rid=None
if rid is not None: if rid is not None:
qry=qry.where(JobPosts.current_recruiter_id==rid) qry=qry.where(JobPosts.has_recruiter(rid))
qry=qry.group_by(cls.status) qry=qry.group_by(cls.status)
result=await session.execute(qry) result=await session.execute(qry)
counts={} counts={}
@ -258,7 +274,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
qry=qry.where(JobPosts.department==department) qry=qry.where(JobPosts.department==department)
rid=cls._as_uuid(recruiter_id) rid=cls._as_uuid(recruiter_id)
if rid is not None: if rid is not None:
qry=qry.where(JobPosts.current_recruiter_id==rid) qry=qry.where(JobPosts.has_recruiter(rid))
qry=qry.group_by(cls.job_post_id) qry=qry.group_by(cls.job_post_id)
result=await session.execute(qry) result=await session.execute(qry)
return {str(job_id):int(n or 0) for job_id,n in result.all()} return {str(job_id):int(n or 0) for job_id,n in result.all()}
@ -667,6 +683,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
education=(extracted.get("education") or "").strip(), education=(extracted.get("education") or "").strip(),
skills=extracted.get("skills") or [], skills=extracted.get("skills") or [],
years_experience=extracted.get("years_experience"), years_experience=extracted.get("years_experience"),
experience="" if extracted.get("years_experience") is None else str(extracted.get("years_experience")),
bank_reason=(bank_reason or "").strip(), bank_reason=(bank_reason or "").strip(),
bank_expires_at=expires_at, bank_expires_at=expires_at,
apply_via="cv_bank", apply_via="cv_bank",
@ -746,6 +763,11 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
row = await session.get(cls, cls._as_uuid(record_id)) row = await session.get(cls, cls._as_uuid(record_id))
if row is None: if row is None:
return None return None
extracted_name = (profile.get("candidate_name") or "").strip()
current_name = (row.candidate_name or "").strip()
email = (row.candidate_email or "").strip()
if extracted_name and (not current_name or current_name.lower() == email.lower()):
row.candidate_name = extracted_name
if not (row.current_company or "").strip(): if not (row.current_company or "").strip():
row.current_company = (profile.get("current_company") or "").strip() row.current_company = (profile.get("current_company") or "").strip()
if not (row.current_position or "").strip(): if not (row.current_position or "").strip():
@ -758,6 +780,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
row.skills = profile.get("skills") or [] row.skills = profile.get("skills") or []
if row.years_experience is None: if row.years_experience is None:
row.years_experience = profile.get("years_experience") row.years_experience = profile.get("years_experience")
if not (row.experience or "").strip() and row.years_experience is not None:
row.experience = str(row.years_experience)
if not (row.linkedin_url or "").strip() and profile.get("linkedin_url"): if not (row.linkedin_url or "").strip() and profile.get("linkedin_url"):
row.linkedin_url = profile["linkedin_url"] row.linkedin_url = profile["linkedin_url"]
row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG
@ -1310,7 +1334,7 @@ class Interviews(SQLModel, table=True):
.outerjoin(Inbox, cls.inbox_id == Inbox.id) .outerjoin(Inbox, cls.inbox_id == Inbox.id)
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id) .outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
.outerjoin(JobPosts, JobPosts.id == job_id) .outerjoin(JobPosts, JobPosts.id == job_id)
.where(JobPosts.current_recruiter_id == rid) .where(JobPosts.has_recruiter(rid))
) )
@classmethod @classmethod
@ -1784,7 +1808,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
rid = cls._as_uuid(recruiter_id) rid = cls._as_uuid(recruiter_id)
if rid is not None: if rid is not None:
statement = statement.where( statement = statement.where(
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
) )
result = await session.execute(statement) result = await session.execute(statement)
value = result.scalar_one() value = result.scalar_one()
@ -1808,7 +1832,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
rid = cls._as_uuid(recruiter_id) rid = cls._as_uuid(recruiter_id)
if rid is not None: if rid is not None:
statement = statement.where( statement = statement.where(
or_(Inbox_Messages.recruiter_id == rid, JobPosts.current_recruiter_id == rid) or_(Inbox_Messages.recruiter_id == rid, JobPosts.has_recruiter(rid))
) )
return statement return statement

View File

@ -14,15 +14,15 @@ from job.feedback.serializers import serialize_feedback
from job.job_post.serializers import serialize_job_post from job.job_post.serializers import serialize_job_post
def _id_str(value): def _id_str(value):
if value in (None, ""): if value in (None,""):
return None return None
return str(value) return str(value)
def _id_list(value): def _id_list(value):
if not value: if not value:
return [] return []
if isinstance(value, (list, tuple)): if isinstance(value,(list,tuple)):
return [str(v) for v in value if v not in (None, "")] return [str(v) for v in value if v not in (None,"")]
return [str(value)] return [str(value)]
def serialize_candidate(row) -> dict: def serialize_candidate(row) -> dict:
@ -104,6 +104,7 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
"current_company":(row.current_company or "").strip() or None, "current_company":(row.current_company or "").strip() or None,
"current_position":(row.current_position or "").strip() or None, "current_position":(row.current_position or "").strip() or None,
"education":(row.education or "").strip() or None, "education":(row.education or "").strip() or None,
"city":(getattr(row,"city",None) or "").strip() or None,
"skills":list(row.skills or []), "skills":list(row.skills or []),
"years_experience":row.years_experience, "years_experience":row.years_experience,
"ai_score":None, "ai_score":None,
@ -155,6 +156,7 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
"current_company":get("current_company") or None, "current_company":get("current_company") or None,
"current_position":get("current_title") or None, "current_position":get("current_title") or None,
"education":get("education") or None, "education":get("education") or None,
"city":get("city") or None,
# Inbox applications never ran the skills extraction — their structured # Inbox applications never ran the skills extraction — their structured
# signal is the ATS score, which is stronger than a keyword list. # signal is the ATS score, which is stronger than a keyword list.
"skills":list(row.get("matched_keywords") or []), "skills":list(row.get("matched_keywords") or []),
@ -301,7 +303,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
"message_id": None, "message_id": None,
"created_at": created, "created_at": created,
"application_status": row.status or None, "application_status": row.status or None,
"experience": (row.experience or "").strip() or None, "experience": (row.experience or "").strip() or (str(row.years_experience) if row.years_experience is not None else None),
"current_employment": company, "current_employment": company,
"current_title": position, "current_title": position,
"resume_text": row.full_text or None, "resume_text": row.full_text or None,
@ -317,7 +319,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
"favorite": None, "favorite": None,
"rating": None, "rating": None,
"phone": (row.candidate_phone or "").strip() or None, "phone": (row.candidate_phone or "").strip() or None,
"education": None, "education": (row.education or "").strip() or None,
"currentCompany": company, "currentCompany": company,
"stage": row.status or None, "stage": row.status or None,
"source": (row.platform or "").strip() or None, "source": (row.platform or "").strip() or None,
@ -449,9 +451,8 @@ _WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
def is_assigned_application(row) -> bool: def is_assigned_application(row) -> bool:
"""True when the row is an application to a real job, not an unassigned email. """True when the row is an application to a real job, not an unassigned email.
Reapplied means they applied to a role before. Another inbox mail with no Sheet forms name a role in job_title even before a job post is linked.
job post is still history; it is not a reapplication. Sheet forms name a Unassigned inbox mail is still a kept attempt see is_kept_application.
role in job_title even before a job post is linked.
""" """
if not isinstance(row, dict): if not isinstance(row, dict):
return False return False
@ -462,6 +463,24 @@ def is_assigned_application(row) -> bool:
return False return False
def is_kept_application(row) -> bool:
"""True when the row is a real application, including unassigned inbox mail.
A CV attachment counts even when text extraction failed (`no_text`) they
still applied. Body-only mail and classifier drops stay in history but do
not count as a reapplication. Two On-Hold emails from the same person do.
"""
if not isinstance(row, dict):
return False
if row.get("source") == "filtered":
return False
if row.get("source") == "inbox" and row.get("attachment") is False:
return False
if row.get("source") == "inbox" and row.get("attachment") is True:
return True
return rejection_reason(row) != "wrong_format"
def rejection_reason(row) -> str | None: def rejection_reason(row) -> str | None:
"""Why an unassigned attempt never reached a job — or None if it is still open. """Why an unassigned attempt never reached a job — or None if it is still open.
@ -508,7 +527,12 @@ def serialize_application_history_item(row) -> dict:
def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict: def serialize_application_history(email, *, user=None, present_in=None, applications=None) -> dict:
items = [serialize_application_history_item(row) for row in (applications or [])] items = [
item for item in (
serialize_application_history_item(row) for row in (applications or [])
)
if is_kept_application(item)
]
found = bool(user or present_in or items) found = bool(user or present_in or items)
return { return {
"email": email, "email": email,
@ -518,6 +542,6 @@ def serialize_application_history(email, *, user=None, present_in=None, applicat
{"id": str(user.id), "name": user.name, "email": user.email} {"id": str(user.id), "name": user.name, "email": user.email}
if user is not None else None if user is not None else None
), ),
"is_reapplicant": any(is_assigned_application(item) for item in items), "is_reapplicant": len(items) > 1,
"applications": items, "applications": items,
} }

View File

@ -25,7 +25,7 @@ from job.candidate.plugins import (
normalize_spaced_text, normalize_spaced_text,
) )
from g_sheet.models import FormData from g_sheet.models import FormData
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_form_candidate_list,serialize_manual_candidate_list,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate from job.candidate.serializers import is_kept_application,serialize_application_history,serialize_application_history_item,serialize_candidate,serialize_candidate_profile,serialize_form_candidate_list,serialize_manual_candidate_list,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
from job.job_post.models import JobPosts from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post from job.job_post.serializers import serialize_job_post
from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE
@ -193,8 +193,8 @@ async def owned_job_ids_for_candidate_scope(session,current_user,created_by=Fals
requisitions.configure (or hiring-manager portal) jobs on their requisitions requisitions.configure (or hiring-manager portal) jobs on their requisitions
/ assigned hiring_manager_id. Recruiter assignment on the job does not hide / assigned hiring_manager_id. Recruiter assignment on the job does not hide
those candidates. candidates.manage or admin None (all applications). those candidates. candidates.manage or admin None (all applications).
Otherwise current_recruiter_id when set, else created_by. Never role_id. Otherwise current_recruiter_ids / current_recruiter_id when set, else created_by. Never role_id.
created_by=True skips current_recruiter_id and matches job_posts.created_by created_by=True skips recruiter assignment and matches job_posts.created_by
to the session user (ignored when the user is requisition-scoped). to the session user (ignored when the user is requisition-scoped).
""" """
if scopes_to_own_requisitions(current_user): if scopes_to_own_requisitions(current_user):
@ -204,14 +204,36 @@ async def owned_job_ids_for_candidate_scope(session,current_user,created_by=Fals
return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by) return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by)
def _requested_job_post_ids(assigned_job_post_id):
"""Comma-separated or list of job post ids → UUID list. None = no filter."""
if assigned_job_post_id is None:
return None
if isinstance(assigned_job_post_id,(list,tuple,set)):
parts=list(assigned_job_post_id)
else:
text=str(assigned_job_post_id).strip()
if not text:
return None
parts=[p.strip() for p in text.split(",") if p.strip()]
ids=[]
seen=set()
for part in parts:
uid=JobPosts._as_uuid(part)
if uid is not None and uid not in seen:
seen.add(uid)
ids.append(uid)
return ids
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False): async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False):
"""None = unscoped list. [] = nothing visible. Else UUID list for the query.""" """None = unscoped list. [] = nothing visible. Else UUID list for the query."""
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by) owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None requested=_requested_job_post_ids(assigned_job_post_id)
if owned is None: if owned is None:
return [requested] if requested else None return requested
if requested is not None: if requested is not None:
return [requested] if requested in set(owned) else [] owned_set=set(owned)
return [jid for jid in requested if jid in owned_set]
return list(owned) return list(owned)
@ -274,12 +296,14 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
""" """
blank={ blank={
"linkedin_url":None,"current_company":"","current_position":"", "linkedin_url":None,"current_company":"","current_position":"",
"education":"","candidate_phone":"","city":None,"skills":[],"years_experience":None, "education":"","candidate_phone":"","city":None,"skills":[],
"years_experience":None,"candidate_name":"",
} }
text=(resume_text or "").strip() text=(resume_text or "").strip()
if not text: if not text:
return blank return blank
try: try:
from employment_agent.decorators import _clean_years
from employment_agent.execute_agent import run_employment_agent from employment_agent.execute_agent import run_employment_agent
from employment_agent.plugins import parse_linkedin from employment_agent.plugins import parse_linkedin
from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY from employment_agent.prompt import CURRENT_TITLE,EDUCATION,NO_COMPANY
@ -296,7 +320,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
url=parse_linkedin({"linkedin_url":fields.get("linkedin_url") or ""},text).get("linkedin_url") url=parse_linkedin({"linkedin_url":fields.get("linkedin_url") or ""},text).get("linkedin_url")
except Exception: except Exception:
url=None url=None
years=fields.get("years_experience") years=_clean_years(fields.get("years_experience"),text)
return { return {
"linkedin_url":url, "linkedin_url":url,
"current_company":unless_sentinel("current_employment",NO_COMPANY), "current_company":unless_sentinel("current_employment",NO_COMPANY),
@ -305,7 +329,8 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
"candidate_phone":(fields.get("phone") or "").strip(), "candidate_phone":(fields.get("phone") or "").strip(),
"city":(fields.get("city") or "").strip() or None, "city":(fields.get("city") or "").strip() or None,
"skills":fields.get("skills") if isinstance(fields.get("skills"),list) else [], "skills":fields.get("skills") if isinstance(fields.get("skills"),list) else [],
"years_experience":years if isinstance(years,int) else None, "years_experience":years,
"candidate_name":(fields.get("candidate_name") or "").strip(),
} }
def _bank_row_matches(row,*,search=None,skills=None,min_years=None,band=None) -> bool: def _bank_row_matches(row,*,search=None,skills=None,min_years=None,band=None) -> bool:
@ -1204,7 +1229,7 @@ class CandidateView:
page=merged[start:start+cap] page=merged[start:start+cap]
return await self.attach_application_history(page),total return await self.attach_application_history(page),total
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False): async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False,assignment=None):
try: try:
if not user_id and is_hiring_manager(current_user): if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
@ -1222,7 +1247,7 @@ class CandidateView:
if list_job_ids is not None and not list_job_ids: if list_job_ids is not None and not list_job_ids:
return [] return []
return await self._list_candidates( return await self._list_candidates(
limit=limit,offset=offset,search=search,job_post_ids=list_job_ids, limit=limit,offset=offset,search=search,job_post_ids=list_job_ids,assignment=assignment,
) )
except HTTPException: except HTTPException:
raise raise
@ -1277,9 +1302,9 @@ class CandidateView:
payload["scored_job_post_id"]=score["job_post_id"] payload["scored_job_post_id"]=score["job_post_id"]
return await self.attach_application_history(payload) return await self.attach_application_history(payload)
async def _list_candidates(self,limit=10,offset=0,search=None,job_post_ids=None): async def _list_candidates(self,limit=10,offset=0,search=None,job_post_ids=None,assignment=None):
rows=await Inbox.get_candidate_profile( rows=await Inbox.get_candidate_profile(
session=self.session,limit=limit,offset=offset,search=search,job_post_ids=job_post_ids, session=self.session,limit=limit,offset=offset,search=search,job_post_ids=job_post_ids,assignment=assignment,
) )
inbox_payloads=await self.attach_job_posts(rows) inbox_payloads=await self.attach_job_posts(rows)
if not isinstance(inbox_payloads,list): if not isinstance(inbox_payloads,list):
@ -1287,14 +1312,16 @@ class CandidateView:
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")} seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()} seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()}
manual_payloads=await self._list_manual_payloads( manual_payloads=await self._list_manual_payloads(
limit=limit,search=search,job_post_ids=job_post_ids,seen=seen,seen_emails=seen_emails, limit=limit,search=search,job_post_ids=job_post_ids,seen=seen,seen_emails=seen_emails,assignment=assignment,
) )
form_payloads=await self._list_form_payloads( form_payloads=await self._list_form_payloads(
limit=limit,search=search,job_post_ids=job_post_ids,seen_emails=seen_emails, limit=limit,search=search,job_post_ids=job_post_ids,seen_emails=seen_emails,assignment=assignment,
) )
return await self.attach_application_history(inbox_payloads+manual_payloads+form_payloads) return await self.attach_application_history(inbox_payloads+manual_payloads+form_payloads)
async def _list_manual_payloads(self,limit,search,job_post_ids,seen,seen_emails): async def _list_manual_payloads(self,limit,search,job_post_ids,seen,seen_emails,assignment=None):
if assignment == "unassigned":
return []
rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool( rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids, self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
) )
@ -1317,9 +1344,9 @@ class CandidateView:
await self._attach_user_ats(payloads) await self._attach_user_ats(payloads)
return payloads return payloads
async def _list_form_payloads(self,limit,search,job_post_ids,seen_emails): async def _list_form_payloads(self,limit,search,job_post_ids,seen_emails,assignment=None):
rows=await FormData.list_for_talent_pool( rows=await FormData.list_for_talent_pool(
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids, self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,assignment=assignment,
) )
payloads=[] payloads=[]
for rec in rows: for rec in rows:
@ -1344,7 +1371,7 @@ class CandidateView:
payload["ai_score"]=row["overall_score"] payload["ai_score"]=row["overall_score"]
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"]) payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False): async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False,assignment=None):
try: try:
job_post_ids=None job_post_ids=None
if not user_id: if not user_id:
@ -1354,15 +1381,15 @@ class CandidateView:
if job_post_ids is not None and not job_post_ids: if job_post_ids is not None and not job_post_ids:
return 0 return 0
inbox_n=await Inbox.count_candidate_profiles( inbox_n=await Inbox.count_candidate_profiles(
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids, session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,assignment=assignment,
) )
if user_id: if user_id:
return inbox_n return inbox_n
manual_n=await Manual_UPLOAD_CANDIDATE.count_for_talent_pool( manual_n=0 if assignment == "unassigned" else await Manual_UPLOAD_CANDIDATE.count_for_talent_pool(
self.session,search=search,job_post_ids=job_post_ids, self.session,search=search,job_post_ids=job_post_ids,
) )
form_n=await FormData.count_for_talent_pool( form_n=await FormData.count_for_talent_pool(
self.session,search=search,job_post_ids=job_post_ids, self.session,search=search,job_post_ids=job_post_ids,assignment=assignment,
) )
return inbox_n+manual_n+form_n return inbox_n+manual_n+form_n
except HTTPException: except HTTPException:
@ -1981,7 +2008,8 @@ class CandidateView:
"""Stamp is_reapplicant + previous_applications onto list/detail dicts. """Stamp is_reapplicant + previous_applications onto list/detail dicts.
``previous_applications`` is every application for that email, including ``previous_applications`` is every application for that email, including
the open row. ``is_reapplicant`` still means a *different* assigned job. the open row. ``is_reapplicant`` means a *different* kept attempt
another email, form, or upload, even when neither has a job assigned.
""" """
single=not isinstance(payloads,list) single=not isinstance(payloads,list)
records=[payloads] if single else list(payloads or []) records=[payloads] if single else list(payloads or [])
@ -1997,7 +2025,7 @@ class CandidateView:
for row in pack.get("applications") or []: for row in pack.get("applications") or []:
item=serialize_application_history_item(row) item=serialize_application_history_item(row)
items.append(item) items.append(item)
if is_assigned_application(item) and not _is_current_application(row,payload): if is_kept_application(item) and not _is_current_application(row,payload):
reapplied=True reapplied=True
items.sort(key=lambda r: r.get("applied_at") or "",reverse=True) items.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
payload["present_in"]=list(pack.get("present_in") or []) payload["present_in"]=list(pack.get("present_in") or [])

View File

@ -92,7 +92,7 @@ class HiringCosts(SQLModel, table=True):
statement = statement.where(JobPosts.department == department) statement = statement.where(JobPosts.department == department)
rid = cls._as_uuid(recruiter_id) rid = cls._as_uuid(recruiter_id)
if rid is not None: if rid is not None:
statement = statement.where(JobPosts.current_recruiter_id == rid) statement = statement.where(JobPosts.has_recruiter(rid))
return statement return statement
@classmethod @classmethod

View File

@ -2,7 +2,8 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, false, func, or_, union_all
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased, load_only from sqlalchemy.orm import aliased, load_only
from sqlmodel import Field, Relationship, SQLModel, select from sqlmodel import Field, Relationship, SQLModel, select
@ -54,8 +55,13 @@ class JobPosts(SQLModel, table=True):
vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"})
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
# Who is working the req now (swappable). History lives in job_assignments # Who is working the req now (swappable). History lives in job_assignments
# with assignment_role=primary_recruiter; this column is the current pointer. # with assignment_role=primary_recruiter; this column is the first / primary
# pointer so existing joins keep working. current_recruiter_ids is the full
# list (UUID strings) so more than one recruiter can sit on the same job.
current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id")
current_recruiter_ids: list[str] = Field(
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
)
# Who owns the requisition (stable). Optional. History lives in # Who owns the requisition (stable). Optional. History lives in
# job_assignments with assignment_role=hiring_manager. # job_assignments with assignment_role=hiring_manager.
hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True) hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True)
@ -77,9 +83,58 @@ class JobPosts(SQLModel, table=True):
def _as_uuid(record_id: str) -> uuid.UUID | None: def _as_uuid(record_id: str) -> uuid.UUID | None:
try: try:
return uuid.UUID(str(record_id)) return uuid.UUID(str(record_id))
except ValueError: except (TypeError, ValueError):
return None return None
@staticmethod
def recruiter_ids_of(row) -> list[str]:
"""UUID strings currently assigned as recruiters on a job row or mapping.
Prefers current_recruiter_ids; falls back to current_recruiter_id so a
row that has not been backfilled still maps to one person.
"""
if isinstance(row, dict):
raw = row.get("current_recruiter_ids")
fallback = row.get("current_recruiter_id")
else:
raw = getattr(row, "current_recruiter_ids", None)
fallback = getattr(row, "current_recruiter_id", None)
out: list[str] = []
seen: set[str] = set()
for item in raw or []:
uid = JobPosts._as_uuid(item)
if uid is None:
continue
key = str(uid)
if key in seen:
continue
seen.add(key)
out.append(key)
if not out:
uid = JobPosts._as_uuid(fallback)
if uid is not None:
out.append(str(uid))
return out
@classmethod
def has_recruiter(cls, recruiter_id):
"""SQL: this recruiter is the primary pointer or in current_recruiter_ids."""
uid = recruiter_id if isinstance(recruiter_id, uuid.UUID) else cls._as_uuid(recruiter_id)
if uid is None:
return false()
return or_(
cls.current_recruiter_id == uid,
cls.current_recruiter_ids.contains([str(uid)]),
)
@classmethod
def no_recruiters(cls):
"""SQL: neither the pointer nor the JSON list names anyone."""
return and_(
cls.current_recruiter_id.is_(None),
func.coalesce(func.jsonb_array_length(cls.current_recruiter_ids), 0) == 0,
)
@classmethod @classmethod
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str): async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
uid = cls._as_uuid(record_id) uid = cls._as_uuid(record_id)
@ -457,6 +512,7 @@ class JobPosts(SQLModel, table=True):
cls.location, cls.location,
cls.requisition_status, cls.requisition_status,
cls.current_recruiter_id, cls.current_recruiter_id,
cls.current_recruiter_ids,
cls.created_at, cls.created_at,
Recruiter.name.label("recruiter_name"), Recruiter.name.label("recruiter_name"),
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"), func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
@ -553,7 +609,7 @@ class JobPosts(SQLModel, table=True):
async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False): async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
"""Jobs this recruiter should see on Candidates (when they lack """Jobs this recruiter should see on Candidates (when they lack
candidates.manage). created_by=True created_by = session user only. candidates.manage). created_by=True created_by = session user only.
Otherwise: current_recruiter_id when set, else created_by.""" Otherwise: current_recruiter_ids / current_recruiter_id when set, else created_by."""
uid = cls._as_uuid(user_id) uid = cls._as_uuid(user_id)
if uid is None: if uid is None:
return [] return []
@ -568,8 +624,8 @@ class JobPosts(SQLModel, table=True):
result = await session.execute( result = await session.execute(
select(cls.id).where( select(cls.id).where(
or_( or_(
and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid), cls.has_recruiter(uid),
and_(cls.current_recruiter_id.is_(None), cls.created_by == uid), and_(cls.no_recruiters(), cls.created_by == uid),
), ),
cls.is_deleted == False, # noqa: E712 cls.is_deleted == False, # noqa: E712
) )
@ -599,12 +655,12 @@ class JobPosts(SQLModel, table=True):
cls, session: AsyncSession, recruiter_id, *, status, department=None, cls, session: AsyncSession, recruiter_id, *, status, department=None,
from_date=None, to_date=None, from_date=None, to_date=None,
): ):
"""Requisitions owned by current_recruiter_id in one requisition_status.""" """Requisitions owned by this recruiter (pointer or JSON list) in one status."""
uid = cls._as_uuid(recruiter_id) uid = cls._as_uuid(recruiter_id)
if uid is None: if uid is None:
return 0 return 0
statement = select(func.count()).select_from(cls).where( statement = select(func.count()).select_from(cls).where(
cls.current_recruiter_id == uid, cls.has_recruiter(uid),
cls.requisition_status == status, cls.requisition_status == status,
cls.is_deleted == False, # noqa: E712 cls.is_deleted == False, # noqa: E712
) )
@ -623,7 +679,7 @@ class JobPosts(SQLModel, table=True):
statement = statement.where(cls.department == department) statement = statement.where(cls.department == department)
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
if uid is not None: if uid is not None:
statement = statement.where(cls.current_recruiter_id == uid) statement = statement.where(cls.has_recruiter(uid))
return statement return statement
@classmethod @classmethod

View File

@ -1,4 +1,5 @@
from job.job_post.enums import RequisitionStatus from job.job_post.enums import RequisitionStatus
from job.job_post.models import JobPosts
def _status_label(value): def _status_label(value):
@ -19,7 +20,22 @@ def serialize_job_post_title(row) -> dict:
} }
def serialize_job_post(row) -> dict: def _recruiter_payload(row, names=None):
"""List of recruiter ids plus mapped names; first id stays the legacy pointer."""
names = names or {}
ids = JobPosts.recruiter_ids_of(row)
mapped = [names.get(i) for i in ids]
first = ids[0] if ids else None
return {
"current_recruiter_id": first,
"current_recruiter_ids": ids,
"recruiter_name": next((n for n in mapped if n), None),
"recruiter_names": [n for n in mapped if n],
"recruiters": [{"id": i, "name": names.get(i)} for i in ids],
}
def serialize_job_post(row, *, names=None) -> dict:
return { return {
"id": str(row.id), "id": str(row.id),
"title": row.title, "title": row.title,
@ -48,10 +64,11 @@ def serialize_job_post(row) -> dict:
"created_at": row.created_at.isoformat() if row.created_at 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, "updated_at": row.updated_at.isoformat() if row.updated_at else None,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
**_recruiter_payload(row, names),
} }
def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict: def serialize_job_row(row, *, names=None, recruiter_name=None, hiring_manager_name=None, applicant_count=0) -> dict:
"""Requisition view of a job post, for the Jobs screen. """Requisition view of a job post, for the Jobs screen.
Deliberately separate from serialize_job_post: that payload is shared by the Deliberately separate from serialize_job_post: that payload is shared by the
@ -59,6 +76,9 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
talent-pool filters key off it on attached job_posts. talent-pool filters key off it on attached job_posts.
""" """
req = getattr(row, "requisition", None) req = getattr(row, "requisition", None)
payload = _recruiter_payload(row, names)
if recruiter_name and not payload["recruiter_name"]:
payload["recruiter_name"] = recruiter_name
return { return {
"id": str(row.id), "id": str(row.id),
"title": row.title, "title": row.title,
@ -79,8 +99,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
"description": row.description, "description": row.description,
"is_active": row.is_active, "is_active": row.is_active,
"closed_at": row.closed_at.isoformat() if row.closed_at else None, "closed_at": row.closed_at.isoformat() if row.closed_at else None,
"current_recruiter_id": str(row.current_recruiter_id) if row.current_recruiter_id else None, **payload,
"recruiter_name": recruiter_name,
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None, "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
"hiring_manager_name": hiring_manager_name, "hiring_manager_name": hiring_manager_name,
"requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None,
@ -94,18 +113,19 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
} }
def serialize_job_stats(row) -> dict: def serialize_job_stats(row, *, names=None) -> dict:
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats.""" """One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
recruiter_id=row.get("current_recruiter_id")
created_at = row.get("created_at") created_at = row.get("created_at")
payload = _recruiter_payload(row, names)
if not payload["recruiter_name"] and row.get("recruiter_name"):
payload["recruiter_name"] = row.get("recruiter_name")
return { return {
"job_post_id": str(row["job_post_id"]), "job_post_id": str(row["job_post_id"]),
"title": row["title"], "title": row["title"],
"department": row["department"] or None, "department": row["department"] or None,
"location": row["location"], "location": row["location"],
"requisition_status": row["requisition_status"], "requisition_status": row["requisition_status"],
"current_recruiter_id": str(recruiter_id) if recruiter_id else None, **payload,
"recruiter_name": row.get("recruiter_name") or None,
# Frontend computes days-open vs client clock; no server days_open field. # Frontend computes days-open vs client clock; no server days_open field.
"created_at": created_at.isoformat() if created_at else None, "created_at": created_at.isoformat() if created_at else None,
"total_applicants": int(row["total_applicants"] or 0), "total_applicants": int(row["total_applicants"] or 0),

View File

@ -40,6 +40,32 @@ IMAGE_TYPE_BY_EXT={"png":"image/png","jpg":"image/jpeg","jpeg":"image/jpeg","web
MAX_JOB_IMAGE_BYTES=5*1024*1024 MAX_JOB_IMAGE_BYTES=5*1024*1024
def _payload_recruiter_ids(payload):
"""Prefer current_recruiter_ids; fall back to current_recruiter_id. None = omitted."""
has_list="current_recruiter_ids" in payload and payload.get("current_recruiter_ids") is not None
has_one="current_recruiter_id" in payload
if has_list:
raw=payload.get("current_recruiter_ids") or []
if not isinstance(raw,(list,tuple)):
raw=[raw]
ids=list(raw)
if not ids and has_one and payload.get("current_recruiter_id") not in (None,""):
ids=[payload.get("current_recruiter_id")]
return ids
if has_one:
raw=payload.get("current_recruiter_id")
return [] if raw in (None,"") else [raw]
return None
def _recruiter_fields(users):
ids=[str(u.id) for u in users]
return {
"current_recruiter_ids": ids,
"current_recruiter_id": users[0].id if users else None,
}
def _job_image_key(job_post_id) -> uuid.UUID: def _job_image_key(job_post_id) -> uuid.UUID:
try: try:
return uuid.UUID(str(job_post_id)) return uuid.UUID(str(job_post_id))
@ -67,6 +93,7 @@ class JobPostCreate(BaseModel):
due_at: str | None = None due_at: str | None = None
hiring_manager_id: UUID | None = None hiring_manager_id: UUID | None = None
current_recruiter_id: UUID | None = None current_recruiter_id: UUID | None = None
current_recruiter_ids: list[UUID] | None = None
requisition_id: UUID | None = None requisition_id: UUID | None = None
@model_validator(mode="after") @model_validator(mode="after")
@ -84,6 +111,31 @@ class JobPost:
self.buffer_api=os.getenv("BUFFER_API") self.buffer_api=os.getenv("BUFFER_API")
self.channel_id=os.getenv("BUFFER_CHANNEL_ID") self.channel_id=os.getenv("BUFFER_CHANNEL_ID")
async def _resolve_recruiters(self,assignment,raw_ids):
"""Validate each id is an active recruiter. Dedup, preserve order."""
users=[]
seen=set()
for raw in raw_ids or []:
if raw in (None,""):
continue
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_ids")
key=str(rec.id)
if key in seen:
continue
seen.add(key)
users.append(rec)
return users
async def _names_for(self,row):
ids=JobPosts.recruiter_ids_of(row)
extra=[]
if getattr(row,"hiring_manager_id",None):
extra.append(row.hiring_manager_id)
return await Users.names_by_ids(self.session,ids+extra)
async def _serialize_post(self,row):
return serialize_job_post(row,names=await self._names_for(row))
async def _resolve_target(self,payload,aliases=None): async def _resolve_target(self,payload,aliases=None):
"""Pick the Buffer channel to post to, and the service it belongs to. """Pick the Buffer channel to post to, and the service it belongs to.
@ -154,12 +206,11 @@ class JobPost:
payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id",
) )
fields["hiring_manager_id"]=hm.id fields["hiring_manager_id"]=hm.id
rec=None rec_users=[]
if payload.get("current_recruiter_id"): raw_ids=_payload_recruiter_ids(payload)
rec=await assignment.require_role( if raw_ids:
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id", rec_users=await self._resolve_recruiters(assignment,raw_ids)
) fields.update(_recruiter_fields(rec_users))
fields["current_recruiter_id"]=rec.id
if payload.get("requisition_id"): if payload.get("requisition_id"):
from candidate_forms.models import Requisition from candidate_forms.models import Requisition
@ -188,8 +239,8 @@ class JobPost:
assigned_by=current_user.get("id") if isinstance(current_user,dict) else None assigned_by=current_user.get("id") if isinstance(current_user,dict) else None
if hm: if hm:
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
if rec: if rec_users:
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) await assignment.record_job_recruiters(row.id,[u.id for u in rec_users],assigned_by)
try: try:
from notifications.views import notify_job_created from notifications.views import notify_job_created
@ -204,7 +255,7 @@ class JobPost:
await self._rank_cv_bank(row.id) await self._rank_cv_bank(row.id)
if not publish: if not publish:
return serialize_job_post(row) return await self._serialize_post(row)
try: try:
post=await create_buffer_post( post=await create_buffer_post(
@ -227,7 +278,7 @@ class JobPost:
sent_at=parse_buffer_datetime(post.get("sentAt")), sent_at=parse_buffer_datetime(post.get("sentAt")),
platform=post.get("channelService"), platform=post.get("channelService"),
) )
return serialize_job_post(saved) return serialize_job_post(saved,names=await self._names_for(saved))
async def _rank_cv_bank(self,job_post_id): async def _rank_cv_bank(self,job_post_id):
"""Queue the tier-1 rank of every banked CV against a brand-new job. """Queue the tier-1 rank of every banked CV against a brand-new job.
@ -281,7 +332,11 @@ class JobPost:
active_only=active_only, active_only=active_only,
restrict_ids=restrict, restrict_ids=restrict,
) )
return [serialize_job_post(r) for r in rows],total names=await Users.names_by_ids(
self.session,
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
)
return [serialize_job_post(r,names=names) for r in rows],total
async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False): async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False):
uid=None uid=None
@ -298,7 +353,11 @@ class JobPost:
skip=skip, skip=skip,
active_only=active_only, active_only=active_only,
) )
data=[serialize_job_stats(r) for r in rows] names=await Users.names_by_ids(
self.session,
[uid for r in rows for uid in JobPosts.recruiter_ids_of(r)],
)
data=[serialize_job_stats(r,names=names) for r in rows]
if uid is not None: if uid is not None:
if not data: if not data:
raise HTTPException(status_code=404,detail="Job post not found") raise HTTPException(status_code=404,detail="Job post not found")
@ -341,13 +400,13 @@ class JobPost:
) )
names=await Users.names_by_ids( names=await Users.names_by_ids(
self.session, self.session,
[r.current_recruiter_id for r in rows]+[r.hiring_manager_id for r in rows], [uid for r in rows for uid in JobPosts.recruiter_ids_of(r)]+[r.hiring_manager_id for r in rows],
) )
counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows]) counts=await Inbox_Messages.counts_by_job_post_ids(self.session,[r.id for r in rows])
return [ return [
serialize_job_row( serialize_job_row(
r, r,
recruiter_name=names.get(str(r.current_recruiter_id)), names=names,
hiring_manager_name=names.get(str(r.hiring_manager_id)), hiring_manager_name=names.get(str(r.hiring_manager_id)),
applicant_count=counts.get(str(r.id),0), applicant_count=counts.get(str(r.id),0),
) )
@ -357,11 +416,11 @@ class JobPost:
async def _job_row(self,row): async def _job_row(self,row):
names=await Users.names_by_ids( names=await Users.names_by_ids(
self.session, self.session,
[row.current_recruiter_id,row.hiring_manager_id], JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
) )
return serialize_job_row( return serialize_job_row(
row, row,
recruiter_name=names.get(str(row.current_recruiter_id)), names=names,
hiring_manager_name=names.get(str(row.hiring_manager_id)), hiring_manager_name=names.get(str(row.hiring_manager_id)),
) )
@ -415,15 +474,12 @@ class JobPost:
detail="This requisition is already linked to a job post", detail="This requisition is already linked to a job post",
) )
fields["requisition_id"]=req.id fields["requisition_id"]=req.id
if "current_recruiter_id" in payload: rec_users=None
raw=payload.get("current_recruiter_id") raw_ids=_payload_recruiter_ids(payload)
if raw is None or raw=="": if raw_ids is not None:
fields["current_recruiter_id"]=None rec_users=await self._resolve_recruiters(assignment,raw_ids)
rec_changed=existing.current_recruiter_id is not None fields.update(_recruiter_fields(rec_users))
else: rec_changed=JobPosts.recruiter_ids_of(existing)!=[str(u.id) for u in rec_users]
rec=await assignment.require_role(raw,EnumRoles.RECRUITER,"current_recruiter_id")
fields["current_recruiter_id"]=rec.id
rec_changed=str(existing.current_recruiter_id)!=str(rec.id)
if not fields: if not fields:
raise HTTPException(status_code=400,detail="No fields to update") raise HTTPException(status_code=400,detail="No fields to update")
@ -443,8 +499,8 @@ class JobPost:
job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by, job_post_id,fields["hiring_manager_id"],"hiring_manager",assigned_by,
) )
if rec_changed: if rec_changed:
await assignment.record_job_owner( await assignment.record_job_recruiters(
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by, job_post_id,fields.get("current_recruiter_ids") or [],assigned_by,
) )
if hm_changed or rec_changed: if hm_changed or rec_changed:
try: try:
@ -458,7 +514,7 @@ class JobPost:
self.session,row, self.session,row,
role_label=" and ".join(labels), role_label=" and ".join(labels),
actor_id=assigned_by, actor_id=assigned_by,
previous_ids=[existing.hiring_manager_id,existing.current_recruiter_id], previous_ids=[existing.hiring_manager_id,*JobPosts.recruiter_ids_of(existing)],
) )
except Exception as exc: except Exception as exc:
logger.warning("notification insert skipped: %s",exc) logger.warning("notification insert skipped: %s",exc)

View File

@ -13,21 +13,21 @@ class Pipeline:
def __init__(self,session:AsyncSession): def __init__(self,session:AsyncSession):
self.session=session self.session=session
async def get_all(self,job_post_id=None,limit=10,offset=0): async def get_all(self,job_post_id=None,limit=10,offset=0,search=None):
# limit/offset are per-source, not a merged page: two tables that cannot be # limit/offset are per-source, not a merged page: two tables that cannot be
# paged as one. limit=10 returns up to 10 inbox AND up to 10 manual rows, # paged as one. limit=10 returns up to 10 inbox AND up to 10 manual rows,
# each newest-first by created_at. `counts`/`total` stay full-set sizes so # each newest-first by created_at. `counts`/`total` stay full-set sizes so
# the caller can drive paging off them. # the caller can drive paging off them.
try: try:
inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset,search=search)
manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset,search=search)
from job.candidate.views import CandidateView from job.candidate.views import CandidateView
history=CandidateView(session=self.session) history=CandidateView(session=self.session)
inbox_data=await history.attach_application_history(inbox_data) inbox_data=await history.attach_application_history(inbox_data)
manual_upload_data=await history.attach_application_history(manual_upload_data) manual_upload_data=await history.attach_application_history(manual_upload_data)
counts=serialize_pipeline_counts( counts=serialize_pipeline_counts(
await Inbox.count_by_status(self.session,job_post_id=job_post_id), await Inbox.count_by_status(self.session,job_post_id=job_post_id,search=search),
await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id), await Manual_UPLOAD_CANDIDATE.count_by_status(self.session,job_post_id=job_post_id,search=search),
) )
return { return {
"data":{"inbox":inbox_data,"manual_upload":manual_upload_data}, "data":{"inbox":inbox_data,"manual_upload":manual_upload_data},

View File

@ -0,0 +1,20 @@
-- 035_job_post_recruiter_ids.sql
-- A job post can have more than one recruiter. current_recruiter_id stays the
-- first / primary pointer so existing joins and filters keep working;
-- current_recruiter_ids is the full JSONB list used by create / update / get.
-- Applied at startup by alembic_setup.run_manual_sql(). Needed because prod
-- boots with DB_AUTOGENERATE=false.
ALTER TABLE app.job_posts
ADD COLUMN IF NOT EXISTS current_recruiter_ids JSONB NOT NULL DEFAULT '[]'::jsonb;
UPDATE app.job_posts
SET current_recruiter_ids = jsonb_build_array(current_recruiter_id::text)
WHERE current_recruiter_id IS NOT NULL
AND (
current_recruiter_ids IS NULL
OR current_recruiter_ids = '[]'::jsonb
);
CREATE INDEX IF NOT EXISTS ix_job_posts_current_recruiter_ids
ON app.job_posts USING GIN (current_recruiter_ids);

View File

@ -209,8 +209,9 @@ async def system_admin_ids(session):
async def job_recruiter_ids(session, job): async def job_recruiter_ids(session, job):
"""Recruiters currently linked to the job post. """Recruiters currently linked to the job post.
Uses the live pointer (current_recruiter_id) and open job_assignments Uses the live pointer (current_recruiter_id), the JSON list
rows with assignment_role=primary_recruiter. (current_recruiter_ids), and open job_assignments rows with
assignment_role=primary_recruiter.
""" """
ids = set() ids = set()
if job is None: if job is None:
@ -218,6 +219,10 @@ async def job_recruiter_ids(session, job):
uid = _as_uuid(getattr(job, "current_recruiter_id", None)) uid = _as_uuid(getattr(job, "current_recruiter_id", None))
if uid is not None: if uid is not None:
ids.add(uid) ids.add(uid)
for raw in getattr(job, "current_recruiter_ids", None) or []:
extra = _as_uuid(raw)
if extra is not None:
ids.add(extra)
from job.assignment.models import JobAssignments from job.assignment.models import JobAssignments
rows = await JobAssignments.fetch_by_job( rows = await JobAssignments.fetch_by_job(
session, job.id, current_only=True, assignment_role="primary_recruiter", session, job.id, current_only=True, assignment_role="primary_recruiter",

View File

@ -133,7 +133,7 @@ class Offers(SQLModel, table=True):
statement = statement.where(JobPosts.department == department) statement = statement.where(JobPosts.department == department)
rid = cls._as_uuid(recruiter_id) rid = cls._as_uuid(recruiter_id)
if rid is not None: if rid is not None:
statement = statement.where(JobPosts.current_recruiter_id == rid) statement = statement.where(JobPosts.has_recruiter(rid))
return statement return statement
@classmethod @classmethod

View File

@ -5,6 +5,7 @@ and how a system-dropped attempt is labelled.
""" """
from job.candidate.serializers import ( from job.candidate.serializers import (
is_assigned_application, is_assigned_application,
is_kept_application,
rejection_reason, rejection_reason,
serialize_application_history, serialize_application_history,
serialize_application_history_item, serialize_application_history_item,
@ -22,6 +23,7 @@ def test_unassigned_inbox_is_not_a_reapplication():
"match_status": "matched", "match_status": "matched",
} }
assert is_assigned_application(row) is False assert is_assigned_application(row) is False
assert is_kept_application(row) is True
assert rejection_reason(row) is None assert rejection_reason(row) is None
@ -48,6 +50,30 @@ def test_unreadable_cv_is_wrong_format():
item = serialize_application_history_item(row) item = serialize_application_history_item(row)
assert item["status"] == "WRONG_FORMAT" assert item["status"] == "WRONG_FORMAT"
assert item["rejection_reason"] == "wrong_format" assert item["rejection_reason"] == "wrong_format"
assert is_kept_application(item) is True
def test_unreadable_cv_plus_later_mail_is_a_reapplication():
"""Attached CVs count even when the first PDF had no extractable text."""
later = {
"source": "inbox",
"message_id": "new",
"job_post_id": None,
"status": "CLOSED",
"attachment": True,
"match_status": "matched",
}
earlier = {
"source": "inbox",
"message_id": "old",
"job_post_id": None,
"status": "CLOSED",
"attachment": True,
"match_status": "no_text",
}
history = serialize_application_history("a@x.com", applications=[later, earlier])
assert history["is_reapplicant"] is True
assert [item["rejection_reason"] for item in history["applications"]] == [None, "wrong_format"]
def test_body_only_mail_is_wrong_format(): def test_body_only_mail_is_wrong_format():
@ -60,20 +86,27 @@ def test_filtered_classifier_row_is_wrong_format():
assert rejection_reason(row) == "wrong_format" assert rejection_reason(row) == "wrong_format"
history = serialize_application_history("a@x.com", applications=[row]) history = serialize_application_history("a@x.com", applications=[row])
assert history["is_reapplicant"] is False assert history["is_reapplicant"] is False
assert history["applications"][0]["status"] == "WRONG_FORMAT" assert history["applications"] == []
def test_history_reapplicant_needs_an_assigned_job(): def test_history_reapplicant_counts_two_unassigned_mails():
unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True} unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True, "match_status": "matched"}
assigned = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer", "status": "PENDING"}
only_mail = serialize_application_history("a@x.com", applications=[unassigned]) only_mail = serialize_application_history("a@x.com", applications=[unassigned])
assert only_mail["is_reapplicant"] is False assert only_mail["is_reapplicant"] is False
assert len(only_mail["applications"]) == 1 both = serialize_application_history("a@x.com", applications=[unassigned, dict(unassigned)])
both = serialize_application_history("a@x.com", applications=[unassigned, assigned])
assert both["is_reapplicant"] is True assert both["is_reapplicant"] is True
assert len(both["applications"]) == 2 assert len(both["applications"]) == 2
def test_wrong_format_plus_one_mail_is_not_a_reapplication():
mail = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True, "match_status": "matched"}
dropped = {"source": "filtered", "job_post_id": None, "status": "WRONG_FORMAT"}
history = serialize_application_history("a@x.com", applications=[mail, dropped])
assert history["is_reapplicant"] is False
assert len(history["applications"]) == 1
assert history["applications"][0]["source"] == "inbox"
def test_clicked_inbox_row_is_not_a_previous_application(): def test_clicked_inbox_row_is_not_a_previous_application():
"""List payloads use `source` for the To address, not 'inbox'.""" """List payloads use `source` for the To address, not 'inbox'."""
pk = "11111111-1111-1111-1111-111111111111" pk = "11111111-1111-1111-1111-111111111111"

View File

@ -0,0 +1,100 @@
"""CV Bank list payload — ATS fields, suggested jobs, scored job."""
from types import SimpleNamespace
from job.candidate.serializers import serialize_bank_candidate, serialize_bank_silver_medalist
def _speculative(**overrides):
row = SimpleNamespace(
id="11111111-1111-1111-1111-111111111111",
candidate_name="Ada Lovelace",
candidate_email="ada@example.com",
candidate_phone="",
file_name="ada.pdf",
file_path="https://s3/ada.pdf",
linkedin_url=None,
current_company="Acme",
current_position="Backend Engineer",
education="",
skills=["Python"],
years_experience=6,
bank_reason="speculative",
bank_expires_at=None,
user_id=None,
job_post_id=None,
created_at=None,
updated_at=None,
)
for key, value in overrides.items():
setattr(row, key, value)
return row
def test_speculative_leaves_ats_empty_for_the_list_join():
payload = serialize_bank_candidate(_speculative())
assert payload["bank_source"] == "speculative"
assert payload["ai_score"] is None
assert payload["suggested_job_post_ids"] == []
assert payload["suggested_jobs"] == []
assert payload["scored_job_post_id"] is None
assert payload["message_id"] is None
assert payload["current_position"] == "Backend Engineer"
assert payload["years_experience"] == 6
assert payload["city"] is None
def test_speculative_keeps_assigned_job_id():
job_id = "22222222-2222-2222-2222-222222222222"
payload = serialize_bank_candidate(_speculative(job_post_id=job_id))
assert payload["assigned_job_post_id"] == job_id
def test_silver_exposes_suggested_jobs_and_inbox_score_path():
payload = serialize_bank_silver_medalist({
"inbox_id": 42,
"message_id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
"name": "Grace Hopper",
"email": "grace@example.com",
"current_company": "Navy",
"current_title": "Rear Admiral",
"matched_keywords": ["COBOL"],
"years_experience": 20,
"ai_score": 88,
"recommendation": "Strong Match",
"assigned_job_post_id": "job-1",
"last_job_post_id": "job-1",
"last_job_title": "Principal Engineer",
"suggested_job_post_ids": ["job-1", "job-2"],
"user_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
"created_at": "2026-08-01T10:00:00Z",
})
assert payload["bank_source"] == "silver_medalist"
assert payload["ai_score"] == 88
assert payload["message_id"] == "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
assert payload["assigned_job_post_id"] == "job-1"
assert payload["scored_job_post_id"] == "job-1"
assert payload["scored_job_title"] == "Principal Engineer"
assert payload["suggested_job_post_ids"] == ["job-1", "job-2"]
assert payload["suggested_jobs"] == []
def test_silver_without_suggestions_stays_empty():
payload = serialize_bank_silver_medalist({
"inbox_id": 7,
"name": "Sparse",
"email": "s@example.com",
"ai_score": 70,
})
assert payload["suggested_job_post_ids"] == []
assert payload["message_id"] is None
assert payload["assigned_job_post_id"] is None
def test_years_from_inbox_free_text():
from inbox.models import _years_from_text
assert _years_from_text("5+ years") == 5
assert _years_from_text("6") == 6
assert _years_from_text(8) == 8
assert _years_from_text(None) is None
assert _years_from_text("") is None

View File

@ -181,3 +181,19 @@ def test_messy_model_city_is_clamped_to_canonical_before_persist():
def test_city_sentinel_is_dropped(): def test_city_sentinel_is_dropped():
assert parse({"city": "no city mentioned"})["city"] is None assert parse({"city": "no city mentioned"})["city"] is None
def test_candidate_name_is_kept_when_it_appears_on_the_resume():
fields = parse({"candidate_name": "Ada Lovelace"})
assert fields["candidate_name"] == "Ada Lovelace"
def test_candidate_name_absent_from_resume_is_dropped():
fields = parse({"candidate_name": "Someone Else"})
assert fields["candidate_name"] == ""
def test_candidate_name_email_and_sentinel_are_dropped():
assert parse({"candidate_name": "ada@example.com"})["candidate_name"] == ""
assert parse({"candidate_name": "no name mentioned"})["candidate_name"] == ""
assert parse({})["candidate_name"] == ""

186
fix_inbox_cities.py Normal file
View File

@ -0,0 +1,186 @@
"""One-off: rewrite inbox_messages.city to a proper city name.
Uses backend/global_cities.py as the city list (every country, not Pakistan-only).
Standalone: does not import the app. Reads backend/.env for DB settings.
python fix_inbox_cities.py
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
import psycopg2
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "backend"))
from global_cities import CITY_BY_KEY, CITY_RE # noqa: E402
# Localities that do not contain the city name (Shahrah-e-Faisal, Malir, …).
ALIASES = {
"malir": "Karachi",
"clifton": "Karachi",
"korangi": "Karachi",
"landhi": "Karachi",
"pechs": "Karachi",
"saddar": "Karachi",
"lyari": "Karachi",
"orangi": "Karachi",
"nazimabad": "Karachi",
"north nazimabad": "Karachi",
"gulshan": "Karachi",
"gulshan e iqbal": "Karachi",
"gulistan e jauhar": "Karachi",
"jauhar": "Karachi",
"shah faisal": "Karachi",
"shah re faisal": "Karachi",
"shah rae faisal": "Karachi",
"shahrah e faisal": "Karachi",
"shahrah faisal": "Karachi",
"shahrae faisal": "Karachi",
"defence": "Karachi",
"johar town": "Lahore",
"model town": "Lahore",
"gulberg": "Lahore",
"township": "Lahore",
"blue area": "Islamabad",
}
DROP = {
"dha", "cantt", "cantonment", "cant", "phase", "sector", "area", "district",
"tehsil", "malir", "gulberg", "clifton", "defence",
}
SECTOR_RE = re.compile(r"^(?:[a-z]-?\d+[a-z]?|\d+[a-z]?)$", re.I)
SENTINELS = {"", "none", "null", "n/a", "-", "na", "n.a.", "n.a"}
def canonical_city(text):
raw = (text or "").strip()
if not raw or raw.lower() in SENTINELS:
return None
known = CITY_BY_KEY.get(raw.lower())
if known:
return known
cleaned = re.sub(r"[()\[\]{}]", " ", raw)
cleaned = re.sub(r"[,/;|]+", " ", cleaned)
cleaned = re.sub(r"\s+", " ", cleaned).strip()
if not cleaned:
return None
known = CITY_BY_KEY.get(cleaned.lower())
if known:
return known
lowered = cleaned.lower()
match = CITY_RE.search(lowered)
if match:
return CITY_BY_KEY[match.group(0)]
hyphen_fold = re.sub(r"[-]+", " ", lowered)
hyphen_fold = re.sub(r"\s+", " ", hyphen_fold).strip()
for alias, city in sorted(ALIASES.items(), key=lambda item: len(item[0]), reverse=True):
if alias in hyphen_fold:
return city
leftover = []
for token in cleaned.split():
lowered_token = token.lower()
if lowered_token in DROP or SECTOR_RE.fullmatch(token):
continue
leftover.append(token)
if leftover:
known = CITY_BY_KEY.get(" ".join(leftover).lower())
if known:
return known
return None
def load_env():
path = ROOT / "backend" / ".env"
out = {}
if not path.exists():
return out
for line in path.read_text(encoding="utf-8-sig").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
out[key.strip()] = value.strip().strip('"').strip("'")
return out
def rewrite_table(cur, table):
cur.execute(
f"SELECT id, city FROM {table} WHERE city IS NOT NULL AND btrim(city) <> ''"
)
rows = cur.fetchall()
updated = 0
skipped = 0
unchanged = 0
samples = []
unmatched = []
for record_id, city in rows:
new = canonical_city(city)
if new is None:
cur.execute(f"UPDATE {table} SET city = NULL WHERE id = %s", (record_id,))
skipped += 1
if len(unmatched) < 30:
unmatched.append(city)
continue
if new == city:
unchanged += 1
continue
cur.execute(f"UPDATE {table} SET city = %s WHERE id = %s", (new, record_id))
updated += 1
if len(samples) < 20:
samples.append((city, new))
print(f"{table}: read={len(rows)} updated={updated} already_ok={unchanged} cleared={skipped}")
for old, new in samples:
print(f" {old!r} -> {new!r}")
for city in unmatched:
print(f" cleared {city!r}")
def dropdown_cities(cur):
cur.execute(
"""
SELECT city FROM inbox_messages
WHERE city IS NOT NULL AND btrim(city) <> '' AND attachment = true
UNION
SELECT city FROM form_data
WHERE city IS NOT NULL AND btrim(city) <> ''
ORDER BY 1
"""
)
return [row[0] for row in cur.fetchall()]
def main():
env = {**os.environ, **load_env()}
kwargs = dict(
host=env.get("DB_HOST", "localhost"),
port=int(env.get("DB_PORT") or 5432),
dbname=env.get("DB_NAME", "hrms"),
user=env.get("DB_USERNAME", "postgres"),
password=env.get("DB_PASSWORD", ""),
options="-c search_path=app,public",
)
sslmode = (env.get("DB_SSLMODE") or "").strip()
if sslmode:
kwargs["sslmode"] = sslmode
conn = psycopg2.connect(**kwargs)
conn.autocommit = False
print(f"db={kwargs['user']}@{kwargs['host']}:{kwargs['port']}/{kwargs['dbname']}")
print(f"cities={len(CITY_BY_KEY)}")
cur = conn.cursor()
rewrite_table(cur, "inbox_messages")
rewrite_table(cur, "form_data")
conn.commit()
names = dropdown_cities(cur)
print(f"inbox city filter ({len(names)}):")
for name in names:
print(f" {name}")
cur.close()
conn.close()
if __name__ == "__main__":
main()

View File

@ -1,6 +1,6 @@
/** /**
* CV Bank mapper the two populations, and the two numbers that must not be * CV Bank mapper speculative vs silver medalist, ATS score, suggested jobs,
* confused (free rank_score vs paid ai_score). * and the per-row job the last ATS ran against.
* *
* node cvbank.test.mjs * node cvbank.test.mjs
*/ */
@ -56,7 +56,6 @@ const speculative = toBankRowView({
years_experience: 6, years_experience: 6,
ai_score: null, ai_score: null,
recommendation: null, recommendation: null,
rank_score: null,
bank_reason: 'speculative', bank_reason: 'speculative',
bank_expires_at: '2028-09-03T00:00:00Z', bank_expires_at: '2028-09-03T00:00:00Z',
created_at: '2026-09-03T10:00:00Z', created_at: '2026-09-03T10:00:00Z',
@ -68,9 +67,13 @@ ok('source label is human', speculative.sourceLabel === 'Speculative')
ok('speculative rows are removable stored CVs', speculative.isStoredCv === true) ok('speculative rows are removable stored CVs', speculative.isStoredCv === true)
ok('extracted skills come through', speculative.skills.join(',') === 'Python,FastAPI,Docker') ok('extracted skills come through', speculative.skills.join(',') === 'Python,FastAPI,Docker')
ok('years is numeric', speculative.years === 6) ok('years is numeric', speculative.years === 6)
ok('role comes through', speculative.title === 'Backend Engineer')
ok('company comes through', speculative.company === 'Acme')
ok('an unscored CV has no ATS score', speculative.aiScore === null) ok('an unscored CV has no ATS score', speculative.aiScore === null)
ok('and no invented band', speculative.recommendation === null) ok('and no invented band', speculative.recommendation === null)
ok('and no rank until a job is picked', speculative.rankScore === null) ok('speculative rows can run bank ATS', speculative.canRunAts === true)
ok('no suggestions stays an empty list', Array.isArray(speculative.suggestedJobs) && speculative.suggestedJobs.length === 0)
ok('no scored job until ATS runs', speculative.scoredJobPostId === null)
ok('expiry parses to a Date', speculative.expiresAt instanceof Date) ok('expiry parses to a Date', speculative.expiresAt instanceof Date)
ok('added parses to a Date', speculative.added instanceof Date) ok('added parses to a Date', speculative.added instanceof Date)
@ -89,6 +92,12 @@ const silver = toBankRowView({
ai_score: 88, ai_score: 88,
recommendation: 'Strong Match', recommendation: 'Strong Match',
last_job_title: 'Principal Engineer', last_job_title: 'Principal Engineer',
assigned_job_post_id: 'job-assigned',
assigned_job_title: 'Principal Engineer',
scored_job_post_id: 'job-assigned',
scored_job_title: 'Principal Engineer',
suggested_jobs: [{ id: 'job-a', title: 'Compiler Engineer' }, { id: 'job-b', title: 'Systems Lead' }],
message_id: 'msg-1',
user_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', user_id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
created_at: '2026-08-01T10:00:00Z', created_at: '2026-08-01T10:00:00Z',
}) })
@ -100,25 +109,39 @@ ok('paid ATS score survives', silver.aiScore === 88)
ok('band survives', silver.recommendation === 'Strong Match') ok('band survives', silver.recommendation === 'Strong Match')
ok('the job they were rejected from is kept for context', silver.lastJobTitle === 'Principal Engineer') ok('the job they were rejected from is kept for context', silver.lastJobTitle === 'Principal Engineer')
ok('userId is kept so the row can open a real profile', silver.userId === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') ok('userId is kept so the row can open a real profile', silver.userId === 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa')
ok('assigned job is shown on the picker', silver.assignedJobPostId === 'job-assigned' && silver.scoredJobTitle === 'Principal Engineer')
ok('suggested job titles come through', silver.suggestedJobs.map((j) => j.title).join(',') === 'Compiler Engineer,Systems Lead')
ok('inbox message lets silver Run ATS via score_inbox', silver.canRunAts === true && silver.messageId === 'msg-1')
/* --- rank_score is the free number, and separate from ai_score ----------- */ const silverNoInbox = toBankRowView({
id: 'app:99',
record_id: '99',
bank_source: 'silver_medalist',
name: 'No Inbox',
ai_score: 70,
})
ok('silver without a message cannot run bank ATS', silverNoInbox.canRunAts === false)
const ranked = toBankRowView({ /* --- ATS score after scoring, independent of suggestions ----------------- */
const scoredBank = toBankRowView({
id: 'bank:2', id: 'bank:2',
record_id: '2', record_id: '2',
bank_source: 'speculative', bank_source: 'speculative',
name: 'Ranked', name: 'Scored',
skills: [], skills: [],
rank_score: 72, ai_score: 84,
ai_score: null, scored_job_post_id: 'job-9',
scored_job_title: 'Backend Engineer',
}) })
ok('rank_score maps without becoming an ATS score', ranked.rankScore === 72 && ranked.aiScore === null) ok('ATS score maps onto the bank row', scoredBank.aiScore === 84)
ok('Pick a job can restore the scored job on load', scoredBank.scoredJobPostId === 'job-9' && scoredBank.scoredJobTitle === 'Backend Engineer')
const bothNumbers = toBankRowView({ const suggestedFromIds = toBankRowView({
id: 'app:3', record_id: '3', bank_source: 'silver_medalist', id: 'app:3', record_id: '3', bank_source: 'silver_medalist',
name: 'Both', rank_score: 61, ai_score: 84, name: 'Ids only', suggested_job_post_ids: ['aaa', 'bbb'],
}) })
ok('a row can carry both numbers independently', bothNumbers.rankScore === 61 && bothNumbers.aiScore === 84) ok('suggested_job_post_ids hydrate when titles were not resolved', suggestedFromIds.suggestedJobs.map((j) => j.id).join(',') === 'aaa,bbb')
/* --- absent values stay absent ------------------------------------------- */ /* --- absent values stay absent ------------------------------------------- */
@ -133,6 +156,7 @@ ok('no skills is an empty array, not null', Array.isArray(sparse.skills) && spar
ok('unknown years stays null, never 0', sparse.years === null) ok('unknown years stays null, never 0', sparse.years === null)
ok('no expiry stays null', sparse.expiresAt === null) ok('no expiry stays null', sparse.expiresAt === null)
ok('unknown source defaults to speculative', sparse.source === 'speculative') ok('unknown source defaults to speculative', sparse.source === 'speculative')
ok('empty suggested-jobs cell stays empty', sparse.suggestedJobs.length === 0)
const zeroYears = toBankRowView({ const zeroYears = toBankRowView({
id: 'bank:5', record_id: '5', bank_source: 'speculative', id: 'bank:5', record_id: '5', bank_source: 'speculative',
@ -142,7 +166,7 @@ ok('0 years is a real value and must not collapse to null', zeroYears.years ===
const derivedBand = toBankRowView({ const derivedBand = toBankRowView({
id: 'app:6', record_id: '6', bank_source: 'silver_medalist', id: 'app:6', record_id: '6', bank_source: 'silver_medalist',
name: 'Derived', ai_score: 70, name: 'Derived', ai_score: 70, message_id: 'm',
}) })
ok('a score without a band derives one', derivedBand.recommendation === 'Potential Match') ok('a score without a band derives one', derivedBand.recommendation === 'Potential Match')

View File

@ -0,0 +1,140 @@
/**
* Reapplied chip two kept applications count, even with no job assigned.
*
* node reapplicant.test.mjs
*/
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import esbuild from 'esbuild'
const outDir = mkdtempSync(join(tmpdir(), 'tf-reapp-'))
const outFile = join(outDir, 'reapplicant.mjs')
await esbuild.build({
entryPoints: ['src/components/ReapplicantHistory.jsx'],
outfile: outFile,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node20',
jsx: 'automatic',
logLevel: 'error',
define: { 'import.meta.env': JSON.stringify({ VITE_API_BASE: '' }) },
})
const { isReapplicant, candidateApplicationsOf, hrefForPreviousApplication } = await import(pathToFileURL(outFile).href)
let failed = 0
function ok(name, cond, extra) {
if (cond) {
console.log(`ok ${name}`)
} else {
failed += 1
console.log(`FAIL ${name}`)
if (extra) console.log(` ${extra}`)
}
}
const current = {
id: 'inbox-2',
inboxId: 'inbox-2',
previousApplications: [
{
source: 'inbox',
inbox_id: 'inbox-2',
message_id: 'inbox-2',
job_post_id: null,
job_title: null,
status: 'CLOSED',
},
{
source: 'inbox',
inbox_id: 'inbox-1',
message_id: 'inbox-1',
job_post_id: null,
job_title: null,
status: 'CLOSED',
},
],
}
ok('two unassigned emails still count as reapplied', isReapplicant(current) === true)
ok('a single application is not reapplied', isReapplicant({
id: 'inbox-1',
previousApplications: [{
source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null,
}],
}) === false)
ok('a wrong-format drop plus one real mail is not reapplied', isReapplicant({
id: 'inbox-1',
previousApplications: [
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'CLOSED', attachment: true },
{ source: 'filtered', message_id: 'drop-1', status: 'WRONG_FORMAT', rejection_reason: 'wrong_format' },
],
}) === false)
ok('an unreadable prior CV is not a reapplication', isReapplicant({
id: 'inbox-2',
previousApplications: [
{ source: 'inbox', inbox_id: 'inbox-2', message_id: 'inbox-2', job_post_id: null, status: 'CLOSED', attachment: true, match_status: 'matched' },
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'WRONG_FORMAT', rejection_reason: 'wrong_format', attachment: true, match_status: 'no_text' },
],
}) === false)
ok('body-only mail plus one real mail is not reapplied', isReapplicant({
id: 'inbox-1',
previousApplications: [
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'CLOSED', attachment: true },
{ source: 'inbox', inbox_id: 'inbox-0', message_id: 'inbox-0', job_post_id: null, status: 'WRONG_FORMAT', rejection_reason: 'wrong_format', attachment: false },
],
}) === false)
ok('an assigned prior application still counts', isReapplicant({
id: 'inbox-2',
previousApplications: [
{ source: 'inbox', inbox_id: 'inbox-2', message_id: 'inbox-2', job_post_id: null },
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: 'job-1', job_title: 'Analyst' },
],
}) === true)
ok('wrong-format rows are omitted from the highlighted list', candidateApplicationsOf({
id: 'inbox-1',
previousApplications: [
{ source: 'inbox', inbox_id: 'inbox-1', message_id: 'inbox-1', job_post_id: null, status: 'CLOSED' },
{ source: 'filtered', message_id: 'drop-1', status: 'WRONG_FORMAT', rejection_reason: 'wrong_format' },
],
}).length === 1)
ok('email history still opens the inbox applicant', hrefForPreviousApplication({
source: 'inbox', message_id: 'msg-1',
}) === '/inbox?open=msg-1&kind=email')
ok('form history still opens the inbox form applicant', hrefForPreviousApplication({
source: 'form', form_data_id: 'form-1',
}) === '/inbox?open=form-1&kind=form')
ok('upload history opens the candidate profile', hrefForPreviousApplication({
source: 'manual', user_id: 'user-1', manual_upload_candidate_id: 'm-1',
}) === '/candidate/user-1')
ok('upload without its own user id uses the open row', hrefForPreviousApplication(
{ source: 'manual', manual_upload_candidate_id: 'm-1' },
{ userId: 'user-9' },
) === '/candidate/user-9')
ok('upload never falls through to matching', hrefForPreviousApplication({
source: 'manual', manual_upload_candidate_id: 'm-1',
}) == null)
rmSync(outDir, { recursive: true, force: true })
if (failed) {
console.log(`\n${failed} check(s) failed`)
process.exit(1)
}
console.log('\nAll reapplicant checks passed')

View File

@ -14,7 +14,7 @@ import { toDate } from '../lib/format'
Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
/managers/fetch. Neither needs rbac_users.view. The current pointers also /managers/fetch. Neither needs rbac_users.view. The current pointers also
live on job_posts (current_recruiter_id, hiring_manager_id) and PATCH live on job_posts (current_recruiter_ids / current_recruiter_id, hiring_manager_id) and PATCH
/jobs/update is the Jobs-screen write path. /jobs/update is the Jobs-screen write path.
============================================================ */ ============================================================ */

View File

@ -287,6 +287,10 @@ export function toApplicationListView(row) {
aiScore: score, aiScore: score,
recommendation: bandOf(score, row.recommendation || null), recommendation: bandOf(score, row.recommendation || null),
applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null), applied: row.created_at ? new Date(row.created_at) : (row.applied ? new Date(row.applied) : null),
assignedJobPostId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
suggestedJobPostIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
isReapplicant: Boolean(row.is_reapplicant), isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
} }
@ -350,6 +354,7 @@ export function toBankRowView(row) {
company: row.current_company ?? null, company: row.current_company ?? null,
title: row.current_position ?? null, title: row.current_position ?? null,
education: row.education ?? null, education: row.education ?? null,
city: row.city ?? null,
skills: Array.isArray(row.skills) ? row.skills : [], skills: Array.isArray(row.skills) ? row.skills : [],
years: Number.isFinite(years) ? years : null, years: Number.isFinite(years) ? years : null,
aiScore, aiScore,
@ -392,9 +397,9 @@ export function expiryLabel(expiresAt, now = new Date()) {
* `search` is an ilike over users.name / users.email only it does NOT reach * `search` is an ilike over users.name / users.email only it does NOT reach
* the résumé text or the suggested job titles. * the résumé text or the suggested job titles.
*/ */
export function list({ search, limit, offset, assignedJobPostId } = {}) { export function list({ search, limit, offset, assignedJobPostId, assignment } = {}) {
return request('/candidate/fetch', { return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId }, params: { search, limit, offset, assigned_job_post_id: assignedJobPostId, assignment },
}) })
} }

View File

@ -20,7 +20,7 @@ export function listMessages() {
* `assigned` is tri-valued: omit for no filter, true for rows with an * `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue. * assigned_job_post_id, false for the Job Matching queue.
*/ */
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source, cityList } = {}) { export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, hasSuggestions, processingState, city, source, cityList, jobPostIds } = {}) {
return request('/inbox/all-applications', { return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults // `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 // to true = no filter), send false for the Unread tab only. buildUrl drops
@ -29,9 +29,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only. // CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
// Same for `is_duplicate`: omit unless the Duplicates tab. // Same for `is_duplicate`: omit unless the Duplicates tab.
// `no_suggestions`: Inbox On-Hold tab — no suggested job post linked. // `no_suggestions`: Inbox On-Hold tab — no suggested job post linked.
// `has_suggestions`: Suggested Match tab — rows with a suggest_job_post_id.
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes // `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
// processed, not application_status PROCESS). // processed, not application_status PROCESS).
// `city`: optional comma-separated list. `source`: channel / platform label. // `city`: optional comma-separated list. `source`: channel / platform label.
// `job_post_ids`: optional comma-separated job post UUIDs (multi-select).
// `city_list`: include merged distinct cities and sources on the same response. // `city_list`: include merged distinct cities and sources on the same response.
params: { params: {
search, search,
@ -43,9 +45,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
assigned, assigned,
is_duplicate: isDuplicate, is_duplicate: isDuplicate,
no_suggestions: noSuggestions, no_suggestions: noSuggestions,
has_suggestions: hasSuggestions,
processing_state: processingState, processing_state: processingState,
city, city,
source, source,
job_post_ids: jobPostIds,
city_list: cityList, city_list: cityList,
}, },
}) })
@ -142,7 +146,7 @@ export function bulkSetRead(recordIds, read) {
* Resolves to `{updated, read}`, where `updated` counts rows that actually * Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast. * CHANGED state, so it is safe to show in a toast.
*/ */
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source } = {}) { export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, hasSuggestions, processingState, city, source, jobPostIds } = {}) {
return request('/inbox/read-all', { return request('/inbox/read-all', {
method: 'PATCH', method: 'PATCH',
body: { body: {
@ -153,9 +157,11 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
assigned, assigned,
is_duplicate: isDuplicate, is_duplicate: isDuplicate,
no_suggestions: noSuggestions, no_suggestions: noSuggestions,
has_suggestions: hasSuggestions,
processing_state: processingState, processing_state: processingState,
city, city,
source, source,
job_post_ids: jobPostIds,
}, },
}) })
} }
@ -168,6 +174,14 @@ export function assignJobPost(recordId, jobPostId) {
}) })
} }
/** Assign (or clear with null) the recruiter for one application. Requires inbox.edit. */
export function assignRecruiter(recordId, recruiterId) {
return request(`/inbox/${recordId}/assign-recruiter`, {
method: 'PATCH',
body: { recruiter_id: recruiterId },
})
}
/** Re-queue the matching agent for one application. Requires inbox.edit. */ /** Re-queue the matching agent for one application. Requires inbox.edit. */
export function rematch(recordId) { export function rematch(recordId) {
return request(`/inbox/${recordId}/match`, { method: 'POST' }) return request(`/inbox/${recordId}/match`, { method: 'POST' })

View File

@ -47,7 +47,12 @@ export function toJobStatsView(row) {
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status ?? '—', status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status ?? '—',
requisitionStatus: row.requisition_status, requisitionStatus: row.requisition_status,
recruiterId: row.current_recruiter_id || null, recruiterId: row.current_recruiter_id || null,
recruiterName: row.recruiter_name || null, recruiterIds: Array.isArray(row.current_recruiter_ids)
? row.current_recruiter_ids.filter(Boolean).map(String)
: (row.current_recruiter_id ? [String(row.current_recruiter_id)] : []),
recruiterName: (Array.isArray(row.recruiter_names) && row.recruiter_names.length
? row.recruiter_names.filter(Boolean)
: (row.recruiter_name ? [row.recruiter_name] : [])).join(', ') || null,
createdAt, createdAt,
daysOpen: daysOpen(createdAt), daysOpen: daysOpen(createdAt),
total: Number(row.total_applicants) || 0, total: Number(row.total_applicants) || 0,

View File

@ -64,8 +64,28 @@ function experienceLabel(min, max) {
return `${min ?? max}+ years` return `${min ?? max}+ years`
} }
function recruiterIdsFrom(row) {
const ids = Array.isArray(row?.current_recruiter_ids)
? row.current_recruiter_ids.filter(Boolean).map(String)
: []
if (ids.length) return ids
return row?.current_recruiter_id ? [String(row.current_recruiter_id)] : []
}
function recruiterNamesFrom(row) {
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
return row.recruiter_names.filter(Boolean)
}
if (Array.isArray(row?.recruiters) && row.recruiters.length) {
return row.recruiters.map((r) => r?.name).filter(Boolean)
}
return row?.recruiter_name ? [row.recruiter_name] : []
}
/** API row -> what the Jobs table and detail modal render. */ /** API row -> what the Jobs table and detail modal render. */
export function toJobView(row) { export function toJobView(row) {
const recruiterIds = recruiterIdsFrom(row)
const recruiterNames = recruiterNamesFrom(row)
return { return {
id: row.id, id: row.id,
title: row.title, title: row.title,
@ -76,8 +96,10 @@ export function toJobView(row) {
platform: row.platform || null, platform: row.platform || null,
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status, status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status,
publishStatus: row.status, publishStatus: row.status,
recruiter: row.recruiter_name, recruiter: recruiterNames.join(', ') || null,
recruiterId: row.current_recruiter_id, recruiterId: recruiterIds[0] || null,
recruiterIds,
recruiterNames,
hiringManager: row.hiring_manager_name, hiringManager: row.hiring_manager_name,
hiringManagerId: row.hiring_manager_id, hiringManagerId: row.hiring_manager_id,
createdByName: row.created_by_name, createdByName: row.created_by_name,

View File

@ -81,9 +81,9 @@ export function changeStage({ inboxId, manualUploadId, toStage, changeReason })
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`. * (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
* `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter. * `jobId === ''` (All Jobs) is dropped by buildUrl and sends no filter.
*/ */
export function listApplications({ jobId, limit, offset } = {}) { export function listApplications({ jobId, limit, offset, search } = {}) {
return request('/pipeline/candidates/fetch', { return request('/pipeline/candidates/fetch', {
params: { job_post_id: jobId, limit, offset }, params: { job_post_id: jobId, limit, offset, search },
}) })
} }

View File

@ -25,11 +25,13 @@ export function listFormDataSheets() {
export function listFormData({ export function listFormData({
sheet, search, offset = 0, limit, processing_state, is_duplicate, sheet, search, offset = 0, limit, processing_state, is_duplicate,
hasLinkedin, hasResume, city, source, assigned, no_suggestions, hasLinkedin, hasResume, city, source, assigned, no_suggestions,
has_suggestions, job_post_ids,
} = {}) { } = {}) {
return request('/sheet/form-data/fetch', { return request('/sheet/form-data/fetch', {
params: { params: {
sheet, search, offset, limit, processing_state, is_duplicate, sheet, search, offset, limit, processing_state, is_duplicate,
has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions,
has_suggestions, job_post_ids,
}, },
}) })
} }
@ -47,9 +49,9 @@ export function countFormData({ sheet } = {}) {
* processing_state or is_duplicate: those two ARE the tabs, and passing them * processing_state or is_duplicate: those two ARE the tabs, and passing them
* would make every badge report the tab the user is already on. * would make every badge report the tab the user is already on.
*/ */
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned } = {}) { export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned, job_post_ids } = {}) {
return request('/sheet/form-data/counts', { return request('/sheet/form-data/counts', {
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned }, params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, job_post_ids },
}) })
} }

View File

@ -66,7 +66,7 @@ export function candidateApplicationsOf(row) {
if (self) items.push(self) if (self) items.push(self)
} }
items.sort((a, b) => (appliedAtMs(b) ?? 0) - (appliedAtMs(a) ?? 0)) items.sort((a, b) => (appliedAtMs(b) ?? 0) - (appliedAtMs(a) ?? 0))
return items return items.filter(isKeptAttempt)
} }
/** Applications other than the open row — used by the Reapplied chip. */ /** Applications other than the open row — used by the Reapplied chip. */
@ -99,6 +99,9 @@ function syntheticCurrentApplication(row) {
job_title: jobTitle, job_title: jobTitle,
status: row.applicationStatus || row.processingState || row.status || null, status: row.applicationStatus || row.processingState || row.status || null,
applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null, applied_at: row.received || row.applied || row.applied_at || row.entry_date || row.when || null,
attachment: row.hasAttachment ?? row.attachment ?? null,
match_status: row.matchStatus || row.match_status || null,
rejection_reason: row.rejectionReason || row.rejection_reason || null,
} }
} }
@ -155,15 +158,25 @@ function isSameApplication(item, currentIds) {
].some((id) => id != null && id !== '' && currentIds.has(String(id))) ].some((id) => id != null && id !== '' && currentIds.has(String(id)))
} }
function hasAssignedJob(item) { function isWrongFormat(item) {
if (!item) return false if (!item) return true
if (item.job_post_id || item.jobPostId) return true if (item.rejection_reason === 'wrong_format' || item.rejectionReason === 'wrong_format') return true
return item.source === 'form' && Boolean(item.job_title || item.jobTitle) if (String(item.status || '').toUpperCase() === 'WRONG_FORMAT') return true
if (item.source === 'filtered') return true
const match = String(item.match_status || item.matchStatus || '').trim().toLowerCase()
if (match === 'no_text' || match === 'failed' || match === 'dlq') return true
if (item.source === 'inbox' && item.attachment === false) return true
return false
}
/** A real prior attempt — unassigned inbox mail counts; a dropped PDF does not. */
function isKeptAttempt(item) {
return Boolean(item) && !isWrongFormat(item)
} }
export function isReapplicant(row) { export function isReapplicant(row) {
if (!row) return false if (!row) return false
return previousApplicationsOf(row).some(hasAssignedJob) return previousApplicationsOf(row).some(isKeptAttempt)
} }
export function previousApplicationsTip(row) { export function previousApplicationsTip(row) {
@ -178,7 +191,7 @@ export function previousApplicationsTip(row) {
/** Compact chip for tables, kanban cards, and inbox rows. */ /** Compact chip for tables, kanban cards, and inbox rows. */
export function ReappliedBadge({ row, className = '' }) { export function ReappliedBadge({ row, className = '' }) {
if (!isReapplicant(row)) return null if (!isReapplicant(row)) return null
const count = previousApplicationsOf(row).filter(hasAssignedJob).length const count = previousApplicationsOf(row).filter(isKeptAttempt).length
return ( return (
<span <span
className={`badge b-amber badge-plain ${className}`.trim()} className={`badge b-amber badge-plain ${className}`.trim()}
@ -190,7 +203,7 @@ export function ReappliedBadge({ row, className = '' }) {
) )
} }
export function hrefForPreviousApplication(item) { export function hrefForPreviousApplication(item, row) {
if (!item) return null if (!item) return null
if (item.source === 'form' && item.form_data_id) { if (item.source === 'form' && item.form_data_id) {
return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form` return `/inbox?open=${encodeURIComponent(item.form_data_id)}&kind=form`
@ -199,10 +212,8 @@ export function hrefForPreviousApplication(item) {
return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email` return `/inbox?open=${encodeURIComponent(item.message_id)}&kind=email`
} }
if (item.source === 'manual') { if (item.source === 'manual') {
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}` const userId = item.user_id || item.userId || row?.userId || row?.user_id
if (item.manual_upload_candidate_id) { return userId ? `/candidate/${encodeURIComponent(userId)}` : null
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
}
} }
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}` if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
if (item.form_data_id) { if (item.form_data_id) {
@ -247,7 +258,7 @@ export function PreviousApplications({ row, title = 'Total applications' }) {
{items.map((item, idx) => { {items.map((item, idx) => {
const stage = applicationStatusLabel(item.status, item) const stage = applicationStatusLabel(item.status, item)
const job = item.job_title || item.jobTitle || 'No job assigned' const job = item.job_title || item.jobTitle || 'No job assigned'
const href = hrefForPreviousApplication(item) const href = hrefForPreviousApplication(item, row)
const isCurrent = current.size > 0 && isSameApplication(item, current) const isCurrent = current.size > 0 && isSameApplication(item, current)
const key = [ const key = [
item.source, item.source,

View File

@ -36,7 +36,7 @@ import { useFormState } from '../components/AuthLayout'
import { persist, useSeedMutation } from '../data/seedQueries' import { persist, useSeedMutation } from '../data/seedQueries'
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
const EMPTY_FILTERS = { account: '', stage: '', band: '' } const EMPTY_FILTERS = { assignment: '', stage: '', band: '' }
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
const STAGE_FILTERS = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected'] const STAGE_FILTERS = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired', 'On Hold', 'Rejected']
const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored'] const BAND_FILTERS = ['Strong Match', 'Potential Match', 'Weak Match', 'Unscored']
@ -55,12 +55,13 @@ const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer
stage, job and recruiter all hang off the application on a users row those stage, job and recruiter all hang off the application on a users row those
columns have no source at all. One row per application is what a recruiter columns have no source at all. One row per application is what a recruiter
triages on, so the table follows the application. */ triages on, so the table follows the application. */
async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId } = {}) { async function fetchCandidates({ limit = DEFAULT_PAGE_SIZE, offset = 0, search, assignedJobPostId, assignment } = {}) {
const res = await candidatesApi.list({ const res = await candidatesApi.list({
limit, limit,
offset, offset,
search: search || undefined, search: search || undefined,
assignedJobPostId: assignedJobPostId || undefined, assignedJobPostId: assignedJobPostId || undefined,
assignment: assignment || undefined,
}) })
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return { return {
@ -324,18 +325,26 @@ function RecruiterCandidates() {
}, [q]) }, [q])
useEffect(() => { setSkip(0) }, [search]) useEffect(() => { setSkip(0) }, [search])
const assignmentParam = filters.assignment === 'Assigned'
? 'assigned'
: filters.assignment === 'Unassigned'
? 'unassigned'
: undefined
const candidatesQuery = useQuery({ const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ queryKey: qk.candidates.list({
limit: pageSize, limit: pageSize,
offset: skip, offset: skip,
search, search,
assignedJobPostId: jobId || undefined, assignedJobPostId: jobId || undefined,
assignment: assignmentParam,
}), }),
queryFn: () => fetchCandidates({ queryFn: () => fetchCandidates({
limit: pageSize, limit: pageSize,
offset: skip, offset: skip,
search, search,
assignedJobPostId: jobId || undefined, assignedJobPostId: jobId || undefined,
assignment: assignmentParam,
}), }),
}) })
const jobsQuery = useQuery({ const jobsQuery = useQuery({
@ -400,8 +409,6 @@ function RecruiterCandidates() {
const rows = useMemo(() => { const rows = useMemo(() => {
const f = filters const f = filters
let list = candidates.filter((c) => { let list = candidates.filter((c) => {
if (f.account === 'Active' && !c.isActive) return false
if (f.account === 'Unconfirmed' && c.isActive) return false
if (f.stage && (c.stage || '') !== f.stage) return false if (f.stage && (c.stage || '') !== f.stage) return false
if (f.band === 'Unscored' && c.aiScore != null) return false if (f.band === 'Unscored' && c.aiScore != null) return false
if (f.band && f.band !== 'Unscored' && recommendationOf(c) !== f.band) return false if (f.band && f.band !== 'Unscored' && recommendationOf(c) !== f.band) return false
@ -609,7 +616,7 @@ function RecruiterCandidates() {
GET /candidate/fetch. */} GET /candidate/fetch. */}
<Facet label="Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} /> <Facet label="Stage" value={filters.stage} onChange={(v) => setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} />
<Facet label="ATS band" value={filters.band} onChange={(v) => setFilter('band', v)} any="Any band" options={BAND_FILTERS} /> <Facet label="ATS band" value={filters.band} onChange={(v) => setFilter('band', v)} any="Any band" options={BAND_FILTERS} />
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any account" options={['Active', 'Unconfirmed']} /> <Facet label="Assignment" value={filters.assignment} onChange={(v) => setFilter('assignment', v)} any="Any assignment" options={['Assigned', 'Unassigned']} />
</div> </div>
)} )}
</div> </div>

View File

@ -540,6 +540,8 @@ export default function CvBank() {
{pickingRow && ( {pickingRow && (
<PickRoleModal <PickRoleModal
title="Pick a job"
subtitle="Search open job posts to score this CV against"
onClose={() => setPickingRow(null)} onClose={() => setPickingRow(null)}
onPick={(post) => { onPick={(post) => {
if (!post?.id) return if (!post?.id) return

View File

@ -23,7 +23,6 @@ import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx' import { exportStyledXlsx } from '../lib/exportXlsx'
import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate, toInstant } from '../lib/format' import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate, toInstant } from '../lib/format'
@ -32,14 +31,15 @@ import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
import * as sheetApi from '../api/sheet' import * as sheetApi from '../api/sheet'
import * as s3Api from '../api/s3' import * as s3Api from '../api/s3'
import * as tasksApi from '../api/tasks'
import { import {
atsRecommendationClass, avatarColor, initials as initialsOf, atsRecommendationClass, avatarColor, initials as initialsOf,
inboxSources, sourceMeta, inboxSources, sourceMeta,
} from '../data/seed' } from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Processed', 'On-Hold', 'Rejected', 'Duplicates'] const TABS = ['All Applications', 'Suggested Match', 'Unread', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
/** Sheet Forms have no mailbox read state — no Unread tab on that channel. */ /** Sheet Forms have no mailbox read state — no Unread tab on that channel. */
const FORM_TABS = ['All Applications', 'Processed', 'On-Hold', 'Rejected', 'Duplicates'] const FORM_TABS = ['All Applications', 'Suggested Match', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */ /** Inbox GET `top` / sheet GET `limit` both cap at 500. */
const PAGE_SIZE_MAX = 500 const PAGE_SIZE_MAX = 500
@ -178,6 +178,7 @@ const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet'
* processing_state / no_suggestions columns on form_data. * processing_state / no_suggestions columns on form_data.
*/ */
const TAB_FILTERS = { const TAB_FILTERS = {
'Suggested Match': { hasSuggestions: true },
Unread: { isread: false }, Unread: { isread: false },
Processed: { processingState: 'processed' }, Processed: { processingState: 'processed' },
'On-Hold': { noSuggestions: true }, 'On-Hold': { noSuggestions: true },
@ -186,6 +187,7 @@ const TAB_FILTERS = {
} }
const FORM_TAB_FILTERS = { const FORM_TAB_FILTERS = {
'Suggested Match': { has_suggestions: true },
Processed: { processing_state: 'processed' }, Processed: { processing_state: 'processed' },
'On-Hold': { no_suggestions: true }, 'On-Hold': { no_suggestions: true },
Rejected: { processing_state: 'rejected' }, Rejected: { processing_state: 'rejected' },
@ -436,6 +438,7 @@ function mapFormRow(row) {
noticePeriod: row.notice_period || '', noticePeriod: row.notice_period || '',
currentSalary: row.current_salary || '', currentSalary: row.current_salary || '',
expectedSalary: row.expected_salary || '', expectedSalary: row.expected_salary || '',
experience: (row.experience || row.experience_details || '').trim(),
profileLink: row.profile_link || '', profileLink: row.profile_link || '',
resumeLink: row.resume_link || '', resumeLink: row.resume_link || '',
sheet: row.sheet || '', sheet: row.sheet || '',
@ -512,6 +515,22 @@ function htmlToText(value) {
return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim()
} }
const AGENT_SENTINELS = new Set([
'no company was mentioned',
'no education mentioned',
'no education mentioned.',
'no job position mentioned',
'no city mentioned',
'no name mentioned',
])
function extractedText(value) {
if (value == null) return ''
const text = String(value).trim()
if (!text) return ''
return AGENT_SENTINELS.has(text.toLowerCase()) ? '' : text
}
/** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */ /** match_status -> the resume badge, mirroring _RESUME_STATUS in inbox/serializers.py. */
const RESUME_STATUS = { const RESUME_STATUS = {
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed', processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
@ -566,6 +585,15 @@ async function fetchMessageDetail(recordId) {
initials: initialsOf(name), initials: initialsOf(name),
color: avatarColor(name), color: avatarColor(name),
email: row.fromEmail || '', email: row.fromEmail || '',
phone: row.phone || '',
experience: row.experience || '',
currentTitle: extractedText(row.current_title),
currentCompany: extractedText(row.current_employment),
city: row.city || '',
residingCity: row.city || '',
education: extractedText(row.education),
recruiter: row.recruiter || '',
recruiterId: row.recruiter_id || null,
position: row.subject || '(no subject)', position: row.subject || '(no subject)',
...sourceFrom(row.message_to), ...sourceFrom(row.message_to),
received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time), received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time),
@ -580,7 +608,6 @@ async function fetchMessageDetail(recordId) {
bodyHtml: row.body || '', bodyHtml: row.body || '',
cc: row.message_cc || '', cc: row.message_cc || '',
bcc: row.message_bcc || '', bcc: row.message_bcc || '',
sentAt: parseGraphDate(row.message_sent_time),
files: Array.isArray(row.files) ? row.files : [], files: Array.isArray(row.files) ? row.files : [],
filePath: row.file_path || '', filePath: row.file_path || '',
linkedinSlug: row.linkedin_slug || '', linkedinSlug: row.linkedin_slug || '',
@ -646,7 +673,11 @@ async function fetchApplications(params) {
atsScore: emailAtsScore(row), atsScore: emailAtsScore(row),
phone: row.phone, phone: row.phone,
experience: row.experience, experience: row.experience,
currentTitle: extractedText(row.current_title),
currentCompany: extractedText(row.current_employment),
education: extractedText(row.education),
recruiter: row.recruiter, recruiter: row.recruiter,
recruiterId: row.recruiter_id || null,
city: row.city || '', city: row.city || '',
residingCity: row.city || '', residingCity: row.city || '',
duplicate: Boolean(row.duplicate), duplicate: Boolean(row.duplicate),
@ -1186,8 +1217,6 @@ export default function Inbox() {
const qc = useQueryClient() const qc = useQueryClient()
const { can } = useAuth() const { can } = useAuth()
const canEdit = can('inbox.edit') const canEdit = can('inbox.edit')
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
const [channel, setChannel] = useState('all') const [channel, setChannel] = useState('all')
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET) const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
@ -1446,6 +1475,7 @@ export default function Inbox() {
: n((isForms ? f : e)[key])) : n((isForms ? f : e)[key]))
return { return {
'All Applications': pick('all'), 'All Applications': pick('all'),
'Suggested Match': pick('suggested'),
Unread: pick('unread'), Unread: pick('unread'),
Processed: pick('processed'), Processed: pick('processed'),
'On-Hold': pick('on_hold'), 'On-Hold': pick('on_hold'),
@ -1524,6 +1554,10 @@ export default function Inbox() {
...(detailQuery.data ?? {}), ...(detailQuery.data ?? {}),
received: selectedRow?.received ?? detailQuery.data?.received ?? null, received: selectedRow?.received ?? detailQuery.data?.received ?? null,
atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null, atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null,
phone: detailQuery.data?.phone || selectedRow?.phone || '',
experience: detailQuery.data?.experience || selectedRow?.experience || '',
recruiter: detailQuery.data?.recruiter || selectedRow?.recruiter || '',
recruiterId: detailQuery.data?.recruiterId || selectedRow?.recruiterId || null,
} }
: null : null
@ -2099,6 +2133,9 @@ export default function Inbox() {
{(i.city || i.residingCity) && ( {(i.city || i.residingCity) && (
<span className="cell-sub">{i.city || i.residingCity}</span> <span className="cell-sub">{i.city || i.residingCity}</span>
)} )}
{orDash(i.phone) !== '—' && (
<span className="cell-sub">{i.phone}</span>
)}
</div> </div>
</div> </div>
</div> </div>
@ -2182,6 +2219,7 @@ export default function Inbox() {
onNote={() => setNoting(selected)} onNote={() => setNoting(selected)}
onReject={() => reject(selected)} onReject={() => reject(selected)}
onToggleDuplicate={() => toggleDuplicate(selected)} onToggleDuplicate={() => toggleDuplicate(selected)}
onAssignRecruiter={setAssigning}
/> />
)} )}
</div> </div>
@ -2193,13 +2231,8 @@ export default function Inbox() {
{assigning && ( {assigning && (
<AssignRecruiter <AssignRecruiter
item={assigning} item={assigning}
recruiters={recruiters} toast={toast}
onClose={() => setAssigning(null)} onClose={() => setAssigning(null)}
onSave={(name) => {
updateInbox((items) => items.map((i) => (i.id === assigning.id ? { ...i, recruiter: name } : i)))
setAssigning(null)
toast(`Recruiter assigned to ${assigning.name}`, 'success')
}}
/> />
)} )}
@ -2228,8 +2261,14 @@ export default function Inbox() {
} }
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */ /** Fields inbox_messages has no column for come back null; show a dash, not "null". */
const PHONE_PLACEHOLDER = /^xxx-xxx-xxxx$/i
function orDash(value, suffix = '') { function orDash(value, suffix = '') {
return value == null || value === '' ? '—' : `${value}${suffix}` if (value == null || value === '') return '—'
const text = String(value).trim()
if (!text || PHONE_PLACEHOLDER.test(text)) return '—'
if (suffix && text.toLowerCase().includes(suffix.trim().toLowerCase())) return text
return `${text}${suffix}`
} }
function externalHref(url) { function externalHref(url) {
@ -2465,6 +2504,7 @@ function FormApplicantDetail({
<div className="info-grid"> <div className="info-grid">
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div> <div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div> <div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div></div> <div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div> <div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div> <div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
@ -2624,7 +2664,7 @@ function FormApplicantDetail({
} }
function ApplicationDetail({ function ApplicationDetail({
item: i, loading, busy, canEdit, toast, onImport, onMove, onNote, onReject, onToggleDuplicate, item: i, loading, busy, canEdit, toast, onImport, onMove, onNote, onReject, onToggleDuplicate, onAssignRecruiter,
}) { }) {
const qc = useQueryClient() const qc = useQueryClient()
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match' const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
@ -2735,7 +2775,10 @@ function ApplicationDetail({
{i.name} {i.name}
<ReappliedBadge row={i} /> <ReappliedBadge row={i} />
</div> </div>
<div className="ph-role">{i.position}</div> <div className="ph-role">
{[extractedText(i.currentTitle), extractedText(i.currentCompany)].filter(Boolean).join(' at ')
|| i.position}
</div>
<div className="ph-tags" style={{ marginTop: 8 }}> <div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '} <SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>} {i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
@ -2763,37 +2806,41 @@ function ApplicationDetail({
<PreviousApplications row={i} /> <PreviousApplications row={i} />
{(resumeKey || i.hasAttachment || profileHref) && ( <div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}> {(resumeKey || i.hasAttachment) && (
{(resumeKey || i.hasAttachment) && ( <button
<button className="btn btn-primary btn-sm"
className="btn btn-primary btn-sm" disabled={openResume.isPending || !canOpenResume}
disabled={openResume.isPending || !canOpenResume} onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))} >
> <Icon name="paperclip" /> Open resume
<Icon name="paperclip" /> Open resume </button>
</button> )}
)} {profileHref && (
{profileHref && ( <a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer"> <Icon name="linkedin" /> LinkedIn
<Icon name="linkedin" /> LinkedIn </a>
</a> )}
)} {canEdit && (
</div> <button className="btn btn-secondary btn-sm" onClick={() => onAssignRecruiter?.(i)}>
)} <Icon name="user" /> {i.recruiter ? 'Change recruiter' : 'Assign recruiter'}
</button>
)}
</div>
<div className="info-grid" style={{ marginBottom: 20 }}> <div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div> <div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div> <div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div> <div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
<div className="info-item"><div className="il">Current title</div><div className="iv">{orDash(extractedText(i.currentTitle))}</div></div>
<div className="info-item"><div className="il">Current company</div><div className="iv">{orDash(extractedText(i.currentCompany))}</div></div>
<div className="info-item"><div className="il">Location</div><div className="iv">{orDash(i.city || i.residingCity)}</div></div>
<div className="info-item"><div className="il">Education</div><div className="iv">{orDash(extractedText(i.education))}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div> <div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item"> <div className="info-item">
<div className="il">Received</div> <div className="il">Received</div>
<div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div> <div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div>
</div> </div>
{i.sentAt && (
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDateTime(i.sentAt)}</div></div>
)}
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>} {i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>} {i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
{i.atsScore != null && ( {i.atsScore != null && (
@ -2853,11 +2900,13 @@ function ApplicationDetail({
gap: 18, gap: 18,
alignItems: 'start', alignItems: 'start',
marginBottom: 20, marginBottom: 20,
minWidth: 0,
maxWidth: '100%',
}} }}
> >
<div style={{ flex: '1 1 320px', minWidth: 0 }}> <div style={{ flex: '1 1 0', minWidth: 0, maxWidth: '100%' }}>
{!loading && ( {!loading && (
<div style={{ marginBottom: 16 }}> <div className="email-pane" style={{ marginBottom: 16 }}>
<div className="email-head">Subject: {i.position || '(no subject)'}</div> <div className="email-head">Subject: {i.position || '(no subject)'}</div>
{looksLikeHtml(i.bodyHtml) ? ( {looksLikeHtml(i.bodyHtml) ? (
<EmailBody html={i.bodyHtml} /> <EmailBody html={i.bodyHtml} />
@ -3019,9 +3068,30 @@ function ApplicationDetail({
) )
} }
function AssignRecruiter({ item, recruiters, onClose, onSave }) { function AssignRecruiter({ item, toast, onClose }) {
const [name, setName] = useState(item.recruiter) const qc = useQueryClient()
const current = recruiters.find((r) => r.name === item.recruiter) const recruitersQuery = useQuery({
queryKey: qk.tasks.assignees(),
queryFn: async () => {
const res = await tasksApi.listAssignees()
return Array.isArray(res?.data) ? res.data : []
},
retry: false,
})
const recruiters = recruitersQuery.data ?? []
const [recruiterId, setRecruiterId] = useState(item.recruiterId || '')
const save = useMutation({
mutationFn: () => inboxApi.assignRecruiter(item.id, recruiterId || null),
onSuccess: () => {
toast(recruiterId ? `Recruiter assigned to ${item.name}` : `${item.name} unassigned from recruiter`, 'success')
onClose()
},
onError: (err) => toast(friendlyAuthError(err, 'Could not assign recruiter'), 'error'),
onSettled: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.message(item.id) })
},
})
return ( return (
<Modal <Modal
title="Assign Recruiter" title="Assign Recruiter"
@ -3030,19 +3100,25 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
footer={ footer={
<> <>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button> <button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={() => onSave(name)}>Assign</button> <button className="btn btn-primary" disabled={save.isPending} onClick={() => save.mutate()}>
Assign
</button>
</> </>
} }
> >
<div className="form-field"> <div className="form-field">
<label>Recruiter</label> <label htmlFor="inbox-assign-recruiter">Recruiter</label>
<select value={name} onChange={(e) => setName(e.target.value)}> <select
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)} id="inbox-assign-recruiter"
value={recruiterId}
onChange={(e) => setRecruiterId(e.target.value)}
>
<option value="">Unassigned</option>
{recruiters.map((r) => (
<option key={r.id} value={r.id}>{r.name}</option>
))}
</select> </select>
</div> </div>
<p className="text-muted text-sm" style={{ marginTop: 10 }}>
Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs.
</p>
</Modal> </Modal>
) )
} }

View File

@ -282,7 +282,7 @@ export default function Jobs() {
{ key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> }, { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => <b>{j.vacancies ?? '—'}</b> },
{ key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> }, { key: 'status', label: 'Status', sortable: true, render: (j) => <Badge>{j.status}</Badge> },
{ key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' }, { key: 'hiringManager', label: 'Hiring Manager', sortable: true, render: (j) => j.hiringManager || '—' },
{ key: 'recruiter', label: 'Recruiter', sortable: true, render: (j) => j.recruiter || '—' }, { key: 'recruiter', label: 'Recruiters', sortable: true, render: (j) => j.recruiter || '—' },
{ {
key: 'created', label: 'Created', sortable: true, key: 'created', label: 'Created', sortable: true,
sortValue: (j) => (j.created ? j.created.getTime() : 0), sortValue: (j) => (j.created ? j.created.getTime() : 0),
@ -527,6 +527,113 @@ function SearchSelect({
) )
} }
function sameIdList(a, b) {
const x = [...(a || [])].map(String)
const y = [...(b || [])].map(String)
return x.length === y.length && x.every((id, i) => id === y[i])
}
/**
* Chip + search picker for more than one recruiter. Value is always an id list.
*/
function RecruiterMultiSelect({
options = [],
value = [],
onChange,
placeholder = 'Search recruiters…',
disabled = false,
loading = false,
}) {
const [q, setQ] = useState('')
const [open, setOpen] = useState(false)
const root = useRef(null)
const selectedIds = (value || []).map(String)
const selected = selectedIds.map((id) => (
options.find((o) => String(o.id) === id) || { id, name: 'Selected recruiter' }
))
useEffect(() => {
function onDoc(e) {
if (root.current && !root.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', onDoc)
return () => document.removeEventListener('mousedown', onDoc)
}, [])
const term = q.trim().toLowerCase()
const filtered = options.filter((o) => {
if (selectedIds.includes(String(o.id))) return false
if (!term) return true
const hay = [o.name, o.email, o.role_name].filter(Boolean).join(' ').toLowerCase()
return hay.includes(term)
})
function add(id) {
const next = String(id)
if (!next || selectedIds.includes(next)) return
onChange([...selectedIds, next])
setQ('')
setOpen(false)
}
function remove(id) {
onChange(selectedIds.filter((x) => x !== String(id)))
}
return (
<div className="job-recruiter-multi" ref={root}>
{selected.length > 0 && (
<div className="job-recruiter-chips">
{selected.map((o) => (
<span className="job-recruiter-chip" key={o.id}>
{o.name}
<button
type="button"
className="job-recruiter-chip-x"
aria-label={`Remove ${o.name}`}
disabled={disabled}
onClick={() => remove(o.id)}
>
×
</button>
</span>
))}
</div>
)}
<div className={`dropdown${open ? ' open' : ''}`} style={{ width: '100%' }}>
<input
value={open ? q : ''}
disabled={disabled || loading}
placeholder={loading ? 'Loading…' : (selected.length ? 'Add another recruiter…' : placeholder)}
autoComplete="off"
onFocus={() => { setOpen(true); setQ('') }}
onChange={(e) => { setQ(e.target.value); setOpen(true) }}
/>
{open && !disabled && !loading && (
<div className="dropdown-menu" style={{ left: 0, right: 'auto', width: '100%', minWidth: 0, maxHeight: 240, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div className="dropdown-link" style={{ color: 'var(--text-3)' }}>
{selected.length && !term ? 'All recruiters selected' : 'No matches'}
</div>
)}
{filtered.map((o) => (
<button
type="button"
key={o.id}
className="dropdown-link"
onClick={() => add(o.id)}
>
{o.name}
{o.email ? <span className="cell-sub" style={{ marginLeft: 8 }}>{o.email}</span> : null}
</button>
))}
</div>
)}
</div>
</div>
)
}
function useManagerDirectory() { function useManagerDirectory() {
return useQuery({ return useQuery({
queryKey: qk.managers.directory(), queryKey: qk.managers.directory(),
@ -593,7 +700,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
const form = useFormState({ const form = useFormState({
hiring_manager_id: '', hiring_manager_id: '',
current_recruiter_id: '', current_recruiter_ids: [],
requisition_id: '', requisition_id: '',
title: '', title: '',
department: '', department: '',
@ -681,7 +788,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
optional_skills: splitLines(v.optional_skills), optional_skills: splitLines(v.optional_skills),
description: v.description.trim() || null, description: v.description.trim() || null,
hiring_manager_id: v.hiring_manager_id || undefined, hiring_manager_id: v.hiring_manager_id || undefined,
current_recruiter_id: v.current_recruiter_id || undefined, current_recruiter_ids: (v.current_recruiter_ids || []).filter(Boolean),
requisition_id: v.requisition_id || undefined, requisition_id: v.requisition_id || undefined,
}, imageFile) }, imageFile)
} }
@ -790,16 +897,14 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
)} )}
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Recruiter</label> <label>Recruiters</label>
<SearchSelect <RecruiterMultiSelect
options={recruitersQuery.data ?? []} options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id} value={form.values.current_recruiter_ids}
onChange={(id) => form.setField('current_recruiter_id', id)} onChange={(ids) => form.setField('current_recruiter_ids', ids)}
placeholder="Search recruiters…" placeholder="Search recruiters…"
disabled={busy} disabled={busy}
loading={recruitersQuery.isPending} loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/> />
{recruitersQuery.isError && ( {recruitersQuery.isError && (
<p className="text-muted text-sm">Recruiter list needs tasks.view you can assign later.</p> <p className="text-muted text-sm">Recruiter list needs tasks.view you can assign later.</p>
@ -979,7 +1084,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_max: j.experienceMax != null ? String(j.experienceMax) : '', experience_max: j.experienceMax != null ? String(j.experienceMax) : '',
description: j.description || '', description: j.description || '',
hiring_manager_id: j.hiringManagerId || '', hiring_manager_id: j.hiringManagerId || '',
current_recruiter_id: j.recruiterId || '', current_recruiter_ids: j.recruiterIds || (j.recruiterId ? [j.recruiterId] : []),
}) })
const assistContext = () => ({ const assistContext = () => ({
@ -1021,7 +1126,7 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max), experience_max: form.values.experience_max === '' ? null : Number(form.values.experience_max),
description: form.values.description.trim() || null, description: form.values.description.trim() || null,
hiring_manager_id: form.values.hiring_manager_id || null, hiring_manager_id: form.values.hiring_manager_id || null,
current_recruiter_id: form.values.current_recruiter_id || null, current_recruiter_ids: (form.values.current_recruiter_ids || []).filter(Boolean),
requisition_id: form.values.requisition_id || null, requisition_id: form.values.requisition_id || null,
}) })
} }
@ -1094,16 +1199,14 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
/> />
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Recruiter</label> <label>Recruiters</label>
<SearchSelect <RecruiterMultiSelect
options={recruitersQuery.data ?? []} options={recruitersQuery.data ?? []}
value={form.values.current_recruiter_id} value={form.values.current_recruiter_ids}
onChange={(id) => form.setField('current_recruiter_id', id)} onChange={(ids) => form.setField('current_recruiter_ids', ids)}
placeholder="Search recruiters…" placeholder="Search recruiters…"
disabled={busy} disabled={busy}
loading={recruitersQuery.isPending} loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/> />
</div> </div>
<div className="form-field"> <div className="form-field">
@ -1202,21 +1305,20 @@ function JobOwnership({ job, canEdit }) {
)} )}
</div> </div>
<div className="form-field"> <div className="form-field">
<label>Recruiter</label> <label>Recruiters</label>
{canEdit ? ( {canEdit ? (
<SearchSelect <RecruiterMultiSelect
options={recruitersQuery.data ?? []} options={recruitersQuery.data ?? []}
value={job.recruiterId || ''} value={job.recruiterIds || (job.recruiterId ? [job.recruiterId] : [])}
onChange={(id) => { onChange={(ids) => {
const next = id || null const next = (ids || []).filter(Boolean).map(String)
if (String(next || '') === String(job.recruiterId || '')) return const current = job.recruiterIds || (job.recruiterId ? [String(job.recruiterId)] : [])
patch.mutate({ current_recruiter_id: next }) if (sameIdList(next, current)) return
patch.mutate({ current_recruiter_ids: next })
}} }}
placeholder="Search recruiters…" placeholder="Search recruiters…"
disabled={patch.isPending} disabled={patch.isPending}
loading={recruitersQuery.isPending} loading={recruitersQuery.isPending}
allowEmpty
emptyLabel="Unassigned"
/> />
) : ( ) : (
<p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p> <p className="text-muted text-sm">{job.recruiter || 'No recruiter assigned yet.'}</p>
@ -1479,7 +1581,7 @@ function JobDetail({
<div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div> <div className="info-item"><div className="il">Created by</div><div className="iv">{j.createdByName || '—'}</div></div>
<div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div> <div className="info-item"><div className="il">Requisition</div><div className="iv">{j.requisitionLabel || '—'}</div></div>
<div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div> <div className="info-item"><div className="il">Hiring Manager</div><div className="iv">{j.hiringManager || '—'}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{j.recruiter || '—'}</div></div> <div className="info-item"><div className="il">Assigned Recruiters</div><div className="iv">{j.recruiter || '—'}</div></div>
<div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div> <div className="info-item"><div className="il">Closed at</div><div className="iv">{j.closedAt ? fmtShort(j.closedAt) : '—'}</div></div>
</div> </div>

View File

@ -14,7 +14,7 @@
with no source is dropped rather than rendered as blanks. with no source is dropped rather than rendered as blanks.
============================================================ */ ============================================================ */
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -44,6 +44,7 @@ export const KANBAN_STAGES = [
const BOARD_LIMIT = 200 const BOARD_LIMIT = 200
const JOB_LIMIT = 100 const JOB_LIMIT = 100
const SEARCH_DEBOUNCE_MS = 300
/** /**
* Highest AI score first, unscored candidates last, newest first within a tie. * Highest AI score first, unscored candidates last, newest first within a tie.
@ -74,8 +75,12 @@ function mapCards(rows, mapper) {
return cards return cards
} }
async function fetchBoard(jobId) { async function fetchBoard(jobId, search) {
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT }) const res = await pipelineApi.listApplications({
jobId,
limit: BOARD_LIMIT,
search: search || undefined,
})
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : [] const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : [] const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
return { return {
@ -114,17 +119,24 @@ export default function Pipeline() {
const qc = useQueryClient() const qc = useQueryClient()
const [jobId, setJobId] = useState('') const [jobId, setJobId] = useState('')
const [q, setQ] = useState('')
const [search, setSearch] = useState('')
const [draggingId, setDraggingId] = useState(null) const [draggingId, setDraggingId] = useState(null)
const [overStage, setOverStage] = useState(null) const [overStage, setOverStage] = useState(null)
useEffect(() => {
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(t)
}, [q])
const boardKey = useMemo( const boardKey = useMemo(
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }), () => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null, search: search || null }),
[jobId], [jobId, search],
) )
const board = useQuery({ const board = useQuery({
queryKey: boardKey, queryKey: boardKey,
queryFn: () => fetchBoard(jobId), queryFn: () => fetchBoard(jobId, search),
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}) })
const jobsQuery = useQuery({ const jobsQuery = useQuery({
@ -222,6 +234,15 @@ export default function Pipeline() {
{total > candidates.length && ` · showing ${candidates.length} of ${total} applications`} {total > candidates.length && ` · showing ${candidates.length} of ${total} applications`}
</>} </>}
actions={<> actions={<>
<div className="toolbar-search" style={{ minWidth: 220 }}>
<Icon name="search" />
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search candidate name or email…"
aria-label="Search candidates"
/>
</div>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}> <select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option> <option value="">All Jobs</option>
{jobs.map((j) => ( {jobs.map((j) => (

View File

@ -223,6 +223,7 @@ export default function Progress() {
const [selectedId, setSelectedId] = useState(deepLinkJobId) const [selectedId, setSelectedId] = useState(deepLinkJobId)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [status, setStatus] = useState('all') const [status, setStatus] = useState('all')
const [sortBy, setSortBy] = useState('applicants')
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
@ -239,9 +240,11 @@ export default function Progress() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const term = query.trim().toLowerCase() const term = query.trim().toLowerCase()
const sortFilter = sortBy === 'completed' ? 'completed' : status
return jobs return jobs
.filter((job) => { .filter((job) => {
if (status !== 'all' && String(job.requisitionStatus || '').toLowerCase() !== status) { const req = String(job.requisitionStatus || '').toLowerCase()
if (sortFilter !== 'all' && req !== sortFilter) {
return false return false
} }
if (!term) return true if (!term) return true
@ -253,7 +256,7 @@ export default function Progress() {
) )
}) })
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title)) .sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
}, [jobs, query, status]) }, [jobs, query, status, sortBy])
const pages = Math.max(1, Math.ceil(filtered.length / pageSize)) const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
const currentPage = Math.min(page, pages) const currentPage = Math.min(page, pages)
@ -264,7 +267,7 @@ export default function Progress() {
useEffect(() => { useEffect(() => {
setPage(1) setPage(1)
}, [query, status]) }, [query, status, sortBy])
useEffect(() => { useEffect(() => {
setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize)) setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize))
@ -372,8 +375,17 @@ export default function Progress() {
<option value="open">Open</option> <option value="open">Open</option>
<option value="on_hold">On hold</option> <option value="on_hold">On hold</option>
<option value="closed">Closed</option> <option value="closed">Closed</option>
<option value="completed">Completed</option>
</select>
<select
className="select"
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
aria-label="Sort applicants"
>
<option value="applicants">Sort: applicants</option>
<option value="completed">Completed</option>
</select> </select>
<span className="progress-sort-chip">Sort: applicants</span>
</div> </div>
</div> </div>

View File

@ -835,6 +835,30 @@ canvas { width: 100%; max-width: 100%; display: block; }
.k-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; } .k-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 8px; }
.tag { font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--text-2); } .tag { font-size: var(--fs-xs); font-weight: 600; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-sunken); color: var(--text-2); }
.job-recruiter-multi { display: flex; flex-direction: column; gap: 8px; }
.job-recruiter-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.job-recruiter-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: var(--radius-sm);
background: var(--bg-sunken);
color: var(--text-2);
font-size: var(--fs-xs);
font-weight: 600;
}
.job-recruiter-chip-x {
border: 0;
background: transparent;
color: var(--text-3);
cursor: pointer;
font-size: 14px;
line-height: 1;
padding: 0;
}
.job-recruiter-chip-x:disabled { cursor: default; opacity: 0.5; }
/* ================= MISC ================= */ /* ================= MISC ================= */
.list-tight > * + * { border-top: 1px solid var(--border); } .list-tight > * + * { border-top: 1px solid var(--border); }
.list-row { display: flex; align-items: center; gap: 12px; padding: 13px 0; } .list-row { display: flex; align-items: center; gap: 12px; padding: 13px 0; }
@ -999,7 +1023,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Split inbox layout */ /* Split inbox layout */
.split { display: grid; grid-template-columns: 380px 1fr; gap: 0; min-height: 560px; } .split { display: grid; grid-template-columns: 380px 1fr; gap: 0; min-height: 560px; }
.split-list { border-right: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); } .split-list { border-right: 1px solid var(--border); overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); }
.split-detail { overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); container-type: inline-size; } .split-detail { overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; max-height: calc(100vh - 260px); max-height: calc(100dvh - 260px); container-type: inline-size; }
/* The detail pane can be narrow while the viewport is wide (split layout), /* The detail pane can be narrow while the viewport is wide (split layout),
so viewport media queries cannot see it: the pane is a size container and so viewport media queries cannot see it: the pane is a size container and
its two-column field grid collapses on the pane's own width. */ its two-column field grid collapses on the pane's own width. */
@ -1300,10 +1324,57 @@ canvas { width: 100%; max-width: 100%; display: block; }
/* Email viewer: a header strip joined to the body below it, Outlook-style. The /* Email viewer: a header strip joined to the body below it, Outlook-style. The
body is either an iframe (HTML mail, see ui/EmailBody.jsx) or a <pre> for body is either an iframe (HTML mail, see ui/EmailBody.jsx) or a <pre> for
plain text both square off their top corners to meet the header. */ plain text both square off their top corners to meet the header. Height
.email-head { border: 1px solid var(--border); border-bottom: none; border-radius: 10px 10px 0 0; background: var(--bg-elev); padding: 10px 14px; font-weight: 600; color: var(--text); font-size: 13px; overflow-wrap: break-word; } follows the text; EmailBody measures the frame so HTML mail is not a 420px
.email-frame { display: block; width: 100%; border: 1px solid var(--border); border-radius: 0 0 10px 10px; background: var(--bg-sunken); } empty well. */
.email-plain { border-radius: 0 0 10px 10px; } .email-pane {
min-width: 0;
max-width: 100%;
overflow: hidden;
}
.email-head {
border: 1px solid var(--border);
border-bottom: none;
border-radius: 10px 10px 0 0;
background: var(--bg-elev);
padding: 10px 16px;
font-weight: 600;
color: var(--text);
font-size: 15px;
line-height: 1.45;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
}
.email-frame {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
height: auto;
min-height: 0;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 0 0 10px 10px;
background: var(--bg-sunken);
}
.email-plain {
display: block;
width: 100%;
max-width: 100%;
min-width: 0;
height: auto;
min-height: 0;
margin: 0;
border-radius: 0 0 10px 10px;
padding: 12px 16px;
font-size: 14px;
line-height: 1.65;
overflow-x: hidden;
overflow-wrap: anywhere;
word-break: break-word;
white-space: pre-wrap;
}
/* Upload dropzone */ /* Upload dropzone */
/* Job detail cover image — banner above the info grid. */ /* Job detail cover image — banner above the info grid. */

View File

@ -68,6 +68,22 @@ function sanitize(html) {
a.setAttribute('rel', 'noopener noreferrer') a.setAttribute('rel', 'noopener noreferrer')
}) })
// Outlook templates pin pixel widths (width="720", min-width:600px). Those
// stretch the iframe past the detail pane. Drop the pins; CSS max-width
// keeps the mail inside the box.
doc.querySelectorAll('table, td, th, img, col').forEach((el) => {
el.removeAttribute('width')
if (el.tagName === 'IMG') el.removeAttribute('height')
})
doc.querySelectorAll('[style]').forEach((el) => {
const style = el.getAttribute('style')
if (!style) return
el.setAttribute(
'style',
style.replace(/(?:min-|max-)?width\s*:\s*\d+(?:\.\d+)?px\s*;?/gi, ''),
)
})
return doc.body?.innerHTML || '' return doc.body?.innerHTML || ''
} }
@ -85,18 +101,39 @@ function frameStyles() {
const dark = document.documentElement.getAttribute('data-theme') === 'dark' const dark = document.documentElement.getAttribute('data-theme') === 'dark'
return ` return `
:root { color-scheme: ${dark ? 'dark' : 'light'}; } :root { color-scheme: ${dark ? 'dark' : 'light'}; }
*, *::before, *::after { box-sizing: border-box; }
html, body {
height: auto;
max-width: 100%;
overflow-x: hidden;
}
body { body {
margin: 0; margin: 0;
padding: 12px 16px;
background: ${pick('--bg-sunken', '#f7f7f8')}; background: ${pick('--bg-sunken', '#f7f7f8')};
color: ${pick('--text', '#111')}; color: ${pick('--text', '#111')};
font-family: ${pick('--sans', 'system-ui, sans-serif')}; font-family: ${pick('--sans', 'system-ui, sans-serif')};
font-size: 13px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
overflow-wrap: break-word; overflow-wrap: anywhere;
word-break: break-word;
} }
img, table { max-width: 100%; } img, svg, video, canvas {
img { height: auto; } max-width: 100% !important;
table { border-collapse: collapse; } height: auto !important;
}
table {
max-width: 100% !important;
width: 100% !important;
border-collapse: collapse;
table-layout: fixed;
}
td, th, p, div, span, li, a, pre, code, h1, h2, h3, h4, h5, h6 {
max-width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
}
pre, code { white-space: pre-wrap !important; }
a { color: ${pick('--primary', '#2563eb')}; } a { color: ${pick('--primary', '#2563eb')}; }
blockquote { blockquote {
margin: 8px 0; padding-left: 12px; margin: 8px 0; padding-left: 12px;
@ -123,10 +160,12 @@ export function looksLikeHtml(value) {
return /<[a-z!/][\s\S]*>/i.test(String(value || '')) return /<[a-z!/][\s\S]*>/i.test(String(value || ''))
} }
const MIN_FRAME_HEIGHT = 48
export default function EmailBody({ html, maxHeight }) { export default function EmailBody({ html, maxHeight }) {
const ref = useRef(null) const ref = useRef(null)
const [allowRemoteImages, setAllowRemoteImages] = useState(false) const [allowRemoteImages, setAllowRemoteImages] = useState(false)
const [height, setHeight] = useState(320) const [height, setHeight] = useState(MIN_FRAME_HEIGHT)
const [blockedImages, setBlockedImages] = useState(0) const [blockedImages, setBlockedImages] = useState(0)
const themeVersion = useThemeVersion() const themeVersion = useThemeVersion()
@ -147,9 +186,17 @@ export default function EmailBody({ html, maxHeight }) {
const measure = useCallback(() => { const measure = useCallback(() => {
const frame = ref.current const frame = ref.current
// contentDocument is readable only because the sandbox keeps allow-same-origin. // contentDocument is readable only because the sandbox keeps allow-same-origin.
const body = frame?.contentDocument?.body const doc = frame?.contentDocument
const body = doc?.body
if (!body) return if (!body) return
setHeight(body.scrollHeight + 8) const htmlEl = doc.documentElement
htmlEl.style.height = 'auto'
body.style.height = 'auto'
const next = Math.ceil(Math.max(body.scrollHeight, htmlEl.scrollHeight || 0))
setHeight((prev) => {
const value = Math.max(MIN_FRAME_HEIGHT, next)
return prev === value ? prev : value
})
}, []) }, [])
const onLoad = useCallback(() => { const onLoad = useCallback(() => {
@ -164,13 +211,27 @@ export default function EmailBody({ html, maxHeight }) {
doc.querySelectorAll('img').forEach((i) => i.addEventListener('load', measure)) doc.querySelectorAll('img').forEach((i) => i.addEventListener('load', measure))
}, [measure, allowRemoteImages]) }, [measure, allowRemoteImages])
useEffect(() => {
setHeight(MIN_FRAME_HEIGHT)
}, [html])
useEffect(() => { useEffect(() => {
window.addEventListener('resize', measure) window.addEventListener('resize', measure)
return () => window.removeEventListener('resize', measure) return () => window.removeEventListener('resize', measure)
}, [measure]) }, [measure])
useEffect(() => {
const doc = ref.current?.contentDocument
const body = doc?.body
if (!body || typeof ResizeObserver === 'undefined') return undefined
const ro = new ResizeObserver(measure)
ro.observe(body)
if (doc.documentElement) ro.observe(doc.documentElement)
return () => ro.disconnect()
}, [srcDoc, measure])
return ( return (
<div> <div style={{ minWidth: 0, maxWidth: '100%', overflow: 'hidden' }}>
{blockedImages > 0 && ( {blockedImages > 0 && (
<div <div
className="flex items-center gap-8" className="flex items-center gap-8"
@ -200,7 +261,13 @@ export default function EmailBody({ html, maxHeight }) {
// No allow-scripts. Ever. See the header comment. // No allow-scripts. Ever. See the header comment.
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
srcDoc={srcDoc} srcDoc={srcDoc}
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }} scrolling="no"
style={{
width: '100%',
maxWidth: '100%',
height: maxHeight ? Math.min(height, maxHeight) : height,
overflow: 'hidden',
}}
/> />
</div> </div>
) )

View File

@ -147,7 +147,12 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
) )
} }
export function PickRoleModal({ onClose, onPick }) { export function PickRoleModal({
onClose,
onPick,
title = 'Choose a different role',
subtitle = 'Search open job posts',
}) {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const { data = [], isPending, isError, error } = useQuery({ const { data = [], isPending, isError, error } = useQuery({
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }), queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
@ -159,8 +164,8 @@ export function PickRoleModal({ onClose, onPick }) {
return ( return (
<Modal <Modal
title="Choose a different role" title={title}
subtitle="Search open job posts" subtitle={subtitle}
size="modal-lg" size="modal-lg"
onClose={onClose} onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>} footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}