Merge pull request 'SQS_BROKER' (#88) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 38s
Details
Deploy to S3 / deploy (push) Successful in 38s
Details
Reviewed-on: #88pull/89/head^2
commit
cff1f74dee
|
|
@ -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({
|
||||||
|
|
@ -101,14 +101,11 @@ def _clean_phone(value,resume_text):
|
||||||
text=(value or "").strip()
|
text=(value or "").strip()
|
||||||
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
||||||
return None
|
return None
|
||||||
digits=re.sub(r"\D","",text)
|
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
|
||||||
if digits.startswith("00"):
|
digits=_phone_digits(text)
|
||||||
digits=digits[2:]
|
if _phone_score(digits)<0:
|
||||||
if len(digits)<10 or len(digits)>15:
|
|
||||||
return None
|
return None
|
||||||
if (resume_text or "").strip():
|
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
|
||||||
haystack=re.sub(r"\D","",resume_text)
|
|
||||||
if digits not in haystack:
|
|
||||||
return None
|
return None
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
@ -194,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.
|
||||||
|
|
||||||
|
|
@ -233,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
|
||||||
|
|
@ -244,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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ Call like the rest of the backend:
|
||||||
fields=parse_linkedin({"linkedin_url":raw},resume_text)
|
fields=parse_linkedin({"linkedin_url":raw},resume_text)
|
||||||
url=fields["linkedin_url"]
|
url=fields["linkedin_url"]
|
||||||
|
|
||||||
`scan_phone` is the regex guts `prefer_extracted_phone` uses so the stacked
|
`scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the
|
||||||
parser cannot recurse into itself.
|
stacked parser cannot recurse into itself.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -23,16 +23,43 @@ from employment_agent.decorators import (
|
||||||
prefer_extracted_phone,
|
prefer_extracted_phone,
|
||||||
)
|
)
|
||||||
|
|
||||||
_PK_MOBILE=re.compile(
|
# PDF extraction uses en/em dashes, nbsp, and bullets as digit separators.
|
||||||
r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}"
|
_DASH_TO_HYPHEN=str.maketrans({
|
||||||
)
|
"\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-",
|
||||||
_PHONE_SPAN=re.compile(
|
"\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-",
|
||||||
r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d"
|
"\uFF0D":"-",
|
||||||
)
|
})
|
||||||
|
_STRIP_INVISIBLE="".join((
|
||||||
|
"\u00ad","\u200b","\u200c","\u200d","\u2060","\ufeff",
|
||||||
|
))
|
||||||
|
_DIGIT_TO_ASCII=str.maketrans({
|
||||||
|
**{chr(0x0660+i):str(i) for i in range(10)},
|
||||||
|
**{chr(0x06F0+i):str(i) for i in range(10)},
|
||||||
|
**{chr(0xFF10+i):str(i) for i in range(10)},
|
||||||
|
})
|
||||||
|
_OCR_O=re.compile(r"(?<![A-Za-z0-9])[Oo](?=3\d{2}[\s\-.\d]{6,})")
|
||||||
|
_DIGIT_GROUP=re.compile(r"\+?\d+")
|
||||||
|
_GAP_OK=re.compile(r"^[\s\-./()[\]{},:|•·∙+_]*$")
|
||||||
|
_WA_ME=re.compile(r"(?i)(?:wa\.me/|api\.whatsapp\.com/send\?phone=)(\+?\d{10,15})")
|
||||||
|
_TEL_URI=re.compile(r"(?i)tel:\s*(\+?[\d\s\-().]{8,22})")
|
||||||
|
_YEAR=re.compile(r"^(?:19|20)\d{2}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_phone_text(text:str) -> str:
|
||||||
|
raw=(text or "").translate(_DIGIT_TO_ASCII).translate(_DASH_TO_HYPHEN)
|
||||||
|
raw=raw.replace("\xa0"," ").replace("\u202f"," ").replace("\u2009"," ")
|
||||||
|
raw=raw.replace("\u2007"," ").replace("\u2028","\n").replace("\u2029","\n")
|
||||||
|
for ch in _STRIP_INVISIBLE:
|
||||||
|
raw=raw.replace(ch,"")
|
||||||
|
return _OCR_O.sub("0",raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _digits_only(raw:str) -> str:
|
||||||
|
return re.sub(r"\D","",_normalize_phone_text(raw or ""))
|
||||||
|
|
||||||
|
|
||||||
def _phone_digits(raw:str) -> str:
|
def _phone_digits(raw:str) -> str:
|
||||||
digits=re.sub(r"\D","",raw or "")
|
digits=_digits_only(raw)
|
||||||
if digits.startswith("00"):
|
if digits.startswith("00"):
|
||||||
digits=digits[2:]
|
digits=digits[2:]
|
||||||
return digits
|
return digits
|
||||||
|
|
@ -54,37 +81,90 @@ def _phone_score(digits:str) -> int:
|
||||||
return n
|
return n
|
||||||
|
|
||||||
|
|
||||||
def scan_phone(text:str) -> str|None:
|
def _phone_keys(digits:str) -> set[str]:
|
||||||
"""Regex scan of CV text — complete numbers only, never a truncated prefix."""
|
"""03XX / +92 3XX / 3XX national forms of the same PK mobile."""
|
||||||
best=None
|
d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "")
|
||||||
best_score=-1
|
if d.startswith("00"):
|
||||||
haystack=text or ""
|
d=d[2:]
|
||||||
for pattern in (_PK_MOBILE,_PHONE_SPAN):
|
keys={d}
|
||||||
for match in pattern.finditer(haystack):
|
if d.startswith("92") and len(d)>=12:
|
||||||
raw=re.sub(r"[\n\r]+"," ",match.group(0))
|
rest=d[2:]
|
||||||
raw=re.sub(r"[\s\-()]+"," ",raw).strip()
|
keys.add(rest)
|
||||||
score=_phone_score(_phone_digits(raw))
|
if rest.startswith("3"):
|
||||||
|
keys.add("0"+rest)
|
||||||
|
if d.startswith("0") and len(d)>=11:
|
||||||
|
keys.add(d[1:])
|
||||||
|
keys.add("92"+d[1:])
|
||||||
|
if d.startswith("3") and len(d)==10:
|
||||||
|
keys.add("0"+d)
|
||||||
|
keys.add("92"+d)
|
||||||
|
return {k for k in keys if len(k)>=10}
|
||||||
|
|
||||||
|
|
||||||
|
def phone_in_resume(digits:str,resume_text:str) -> bool:
|
||||||
|
"""True when this number (or its 03 / +92 twin) appears in the CV digits."""
|
||||||
|
haystack=_digits_only(resume_text)
|
||||||
|
if not haystack:
|
||||||
|
return True
|
||||||
|
return any(key in haystack for key in _phone_keys(digits))
|
||||||
|
|
||||||
|
|
||||||
|
def _tidy_raw(raw:str) -> str:
|
||||||
|
compact=re.sub(r"[\n\r]+"," ",raw or "")
|
||||||
|
compact=re.sub(r"[ \t]+"," ",compact)
|
||||||
|
return compact.strip(" \t-./()[]{},:|•·∙_")
|
||||||
|
|
||||||
|
|
||||||
|
def _consider(raw:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||||
|
value=_tidy_raw(raw)
|
||||||
|
score=_phone_score(_phone_digits(value))
|
||||||
if score>best_score:
|
if score>best_score:
|
||||||
best_score=score
|
return value,score
|
||||||
best=raw
|
return best,best_score
|
||||||
if best_score>=180:
|
|
||||||
return best
|
|
||||||
|
def _scan_digit_groups(text:str,best:str|None,best_score:int) -> tuple[str|None,int]:
|
||||||
|
groups=list(_DIGIT_GROUP.finditer(text))
|
||||||
|
for i,start_g in enumerate(groups):
|
||||||
|
acc=start_g.group(0)
|
||||||
|
end=start_g.end()
|
||||||
|
best,best_score=_consider(acc,best,best_score)
|
||||||
|
for nxt in groups[i+1:]:
|
||||||
|
gap=text[end:nxt.start()]
|
||||||
|
if not _GAP_OK.match(gap):
|
||||||
|
break
|
||||||
|
nxt_digits=nxt.group(0).lstrip("+")
|
||||||
|
if _YEAR.match(nxt_digits) and len(_phone_digits(acc))>=10:
|
||||||
|
break
|
||||||
|
combined=_phone_digits(acc+nxt.group(0))
|
||||||
|
if len(combined)>15:
|
||||||
|
break
|
||||||
|
acc=text[start_g.start():nxt.end()]
|
||||||
|
end=nxt.end()
|
||||||
|
best,best_score=_consider(acc,best,best_score)
|
||||||
|
return best,best_score
|
||||||
|
|
||||||
|
|
||||||
|
def scan_phone(text:str) -> str|None:
|
||||||
|
"""Scan CV text for a complete phone — unicode separators, wrap, tel/wa.me."""
|
||||||
|
haystack=_normalize_phone_text(text or "")
|
||||||
|
best,best_score=None,-1
|
||||||
|
best,best_score=_scan_digit_groups(haystack,best,best_score)
|
||||||
|
for pattern in (_WA_ME,_TEL_URI):
|
||||||
|
for match in pattern.finditer(haystack):
|
||||||
|
best,best_score=_consider(match.group(1),best,best_score)
|
||||||
return best
|
return best
|
||||||
|
|
||||||
|
|
||||||
def prefer_full_phone(*candidates) -> str|None:
|
def prefer_full_phone(*candidates) -> str|None:
|
||||||
"""Keep the candidate with the most digits (min 10). Truncated regex loses."""
|
"""Keep the strongest complete number. Truncated / CNIC-shaped values lose."""
|
||||||
best=None
|
best,best_score=None,-1
|
||||||
best_n=-1
|
|
||||||
for raw in candidates:
|
for raw in candidates:
|
||||||
value=(raw or "").strip()
|
value=(raw or "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
continue
|
continue
|
||||||
n=len(_phone_digits(value))
|
best,best_score=_consider(value,best,best_score)
|
||||||
if n>=10 and n>best_n:
|
return best if best_score>=0 else None
|
||||||
best_n=n
|
|
||||||
best=value
|
|
||||||
return best
|
|
||||||
|
|
||||||
|
|
||||||
def _as_str(data,key):
|
def _as_str(data,key):
|
||||||
|
|
|
||||||
|
|
@ -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 country→cities 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 country→cities 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.
|
||||||
|
|
@ -80,7 +87,9 @@ phone (its own key — extract this separately; copy EVERY digit):
|
||||||
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
- Return the candidate's own mobile / phone exactly as written, including country code when present.
|
||||||
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full — never stop after 7 or 8 digits.
|
- Pakistani mobiles are 11 digits local (03XX-XXXXXXX / 03XX XXXXXXX) or +92 3XX XXXXXXX (12 digits with country code). Copy the last group in full — never stop after 7 or 8 digits.
|
||||||
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
|
- If PDF extraction wrapped the number across lines (e.g. "0321-5551\\n234"), join the groups into one complete number.
|
||||||
- Spaces, hyphens, and parentheses are allowed; do not delete trailing digits to "clean" the value.
|
- Spaces, hyphens, parentheses, en-dashes, bullets, and non-breaking spaces are allowed; do not delete trailing digits to "clean" the value.
|
||||||
|
- A Phone / Mobile / Cell / WhatsApp / Tel label may sit on the line above the digits — still copy the number.
|
||||||
|
- 03XX-XXXXXXX and +92 3XX XXXXXXX are the same number; return the form written on the resume.
|
||||||
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
|
- Do not invent a number. If none is mentioned, return exactly: {NO_PHONE}
|
||||||
|
|
||||||
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
|
||||||
|
|
@ -89,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",
|
||||||
|
|
@ -144,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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_, update
|
from sqlalchemy import Column, DateTime, Index, and_, case, delete, false, func, insert, or_, update
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlmodel import Field, SQLModel, select
|
from sqlmodel import Field, SQLModel, select
|
||||||
|
|
@ -127,6 +127,108 @@ class FormData(SQLModel, table=True):
|
||||||
else_=False,
|
else_=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _suggested_contains_any(cls, job_post_ids):
|
||||||
|
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||||
|
if not ids:
|
||||||
|
return false()
|
||||||
|
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _has_job_link(cls):
|
||||||
|
return or_(
|
||||||
|
cls.assigned_job_post_id.is_not(None),
|
||||||
|
cls.job_post_id.is_not(None),
|
||||||
|
~cls._no_suggested_jobs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _matches_any_job(cls, job_post_ids):
|
||||||
|
ids = list(job_post_ids or [])
|
||||||
|
if not ids:
|
||||||
|
return false()
|
||||||
|
return or_(
|
||||||
|
cls.assigned_job_post_id.in_(ids),
|
||||||
|
cls.job_post_id.in_(ids),
|
||||||
|
cls._suggested_contains_any(ids),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
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."""
|
||||||
|
filters = [cls.manual_upload_candidate_id.is_(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)))
|
||||||
|
else:
|
||||||
|
filters.append(cls._has_job_link())
|
||||||
|
if search:
|
||||||
|
like = f"%{search.strip()}%"
|
||||||
|
filters.append(or_(cls.name.ilike(like), cls.candidate_email.ilike(like)))
|
||||||
|
return filters
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
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."""
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return []
|
||||||
|
qry = (
|
||||||
|
select(cls)
|
||||||
|
.where(*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment))
|
||||||
|
.order_by(cls.created_at.desc(), cls.id.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
result = await session.execute(qry)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
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):
|
||||||
|
return 0
|
||||||
|
qry = select(func.count()).select_from(cls).where(
|
||||||
|
*cls._talent_pool_filters(search=search, job_post_ids=job_post_ids, assignment=assignment)
|
||||||
|
)
|
||||||
|
result = await session.execute(qry)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
@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)."""
|
||||||
|
|
@ -143,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:
|
||||||
|
|
@ -151,6 +253,9 @@ 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:
|
||||||
|
if is_duplicate:
|
||||||
|
filters.append(cls._duplicates_tab_filter())
|
||||||
|
else:
|
||||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
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]
|
||||||
|
|
@ -188,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":
|
||||||
|
|
@ -383,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())
|
||||||
|
|
@ -393,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:
|
||||||
|
|
@ -553,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(
|
||||||
|
|
@ -562,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)
|
||||||
|
|
@ -571,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.
|
||||||
|
|
||||||
|
|
@ -590,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()
|
||||||
|
|
@ -608,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
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,11 +279,17 @@ 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"),
|
||||||
Inbox_Messages.file_name,
|
Inbox_Messages.file_name,
|
||||||
Inbox_Messages.file_path,
|
Inbox_Messages.file_path,
|
||||||
Inbox_Messages.ats_score,
|
Inbox_Messages.ats_score,
|
||||||
Inbox_Messages.ats_band,
|
Inbox_Messages.ats_band,
|
||||||
|
Inbox_Messages.assigned_job_post_id,
|
||||||
|
Inbox_Messages.suggested_job_post_ids,
|
||||||
cls.created_at,
|
cls.created_at,
|
||||||
|
JobPosts.id.label("last_job_post_id"),
|
||||||
JobPosts.title.label("last_job_title"),
|
JobPosts.title.label("last_job_title"),
|
||||||
Candidates.matched_keywords,
|
Candidates.matched_keywords,
|
||||||
Candidates.years_experience,
|
Candidates.years_experience,
|
||||||
|
|
@ -298,13 +322,18 @@ 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,
|
||||||
"recommendation":row["ats_band"] or None,
|
"recommendation":row["ats_band"] or None,
|
||||||
|
"message_id":str(row["message_id"]) if row["message_id"] else None,
|
||||||
|
"assigned_job_post_id":str(row["assigned_job_post_id"]) if row["assigned_job_post_id"] else None,
|
||||||
|
"suggested_job_post_ids":list(row["suggested_job_post_ids"] or []),
|
||||||
|
"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"],
|
||||||
})
|
})
|
||||||
|
|
@ -332,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=(
|
||||||
|
|
@ -349,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():
|
||||||
|
|
@ -366,7 +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
|
||||||
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):
|
def _with_job_link(cls, qry, job_post_ids, assignment=None):
|
||||||
|
"""List mode: keep only applications with an assigned post or suggestions.
|
||||||
|
|
||||||
|
`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
|
||||||
|
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).
|
||||||
|
|
||||||
|
`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)
|
||||||
|
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:
|
||||||
|
return qry.where(Inbox_Messages._matches_any_job(list(job_post_ids)))
|
||||||
|
return qry.where(Inbox_Messages._has_job_link())
|
||||||
|
|
||||||
|
@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,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 []
|
||||||
|
|
@ -388,11 +450,8 @@ class Inbox(SQLModel, table=True):
|
||||||
qry = qry.where(cls.user_id == user_id)
|
qry = qry.where(cls.user_id == user_id)
|
||||||
if search:
|
if search:
|
||||||
qry = qry.where(cls._candidate_search_filter(search))
|
qry = qry.where(cls._candidate_search_filter(search))
|
||||||
if job_post_ids is not None:
|
if not user_id:
|
||||||
qry = (
|
qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
|
||||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
|
||||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
|
||||||
)
|
|
||||||
# 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())
|
||||||
|
|
@ -406,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):
|
||||||
|
|
@ -422,11 +481,8 @@ class Inbox(SQLModel, table=True):
|
||||||
qry = qry.where(cls.user_id == user_id)
|
qry = qry.where(cls.user_id == user_id)
|
||||||
if search:
|
if search:
|
||||||
qry = qry.where(cls._candidate_search_filter(search))
|
qry = qry.where(cls._candidate_search_filter(search))
|
||||||
if job_post_ids is not None:
|
if not user_id:
|
||||||
qry = (
|
qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
|
||||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
|
||||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
|
||||||
)
|
|
||||||
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:
|
||||||
|
|
@ -475,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
|
||||||
|
|
||||||
|
|
@ -1026,6 +1082,56 @@ class Inbox_Messages(SQLModel, table=True):
|
||||||
else_=False,
|
else_=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _suggested_contains_any(cls, job_post_ids):
|
||||||
|
"""JSONB @> '["uuid"]' for any id — suggested_job_post_ids stores strings."""
|
||||||
|
ids = [str(jid) for jid in (job_post_ids or []) if jid]
|
||||||
|
if not ids:
|
||||||
|
return false()
|
||||||
|
return or_(*(cls.suggested_job_post_ids.contains([sid]) for sid in ids))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _has_job_link(cls):
|
||||||
|
return or_(cls.assigned_job_post_id.is_not(None), ~cls._no_suggested_jobs())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _matches_any_job(cls, job_post_ids):
|
||||||
|
ids = list(job_post_ids or [])
|
||||||
|
if not ids:
|
||||||
|
return false()
|
||||||
|
return or_(
|
||||||
|
cls.assigned_job_post_id.in_(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)."""
|
||||||
|
|
@ -1049,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.
|
||||||
|
|
||||||
|
|
@ -1068,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()]
|
||||||
|
|
@ -1112,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)
|
||||||
|
|
@ -1210,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.
|
||||||
|
|
@ -1228,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()
|
||||||
|
|
@ -1351,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.
|
||||||
|
|
||||||
|
|
@ -1362,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(
|
||||||
|
|
@ -1378,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
|
||||||
|
|
@ -1392,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),
|
||||||
}
|
}
|
||||||
|
|
@ -1470,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
|
||||||
|
|
||||||
|
|
@ -1537,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)
|
||||||
|
|
@ -1581,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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -197,17 +197,23 @@ 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
|
||||||
|
body = (
|
||||||
|
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
||||||
|
f"{job.title}. Open the CV Bank to review them."
|
||||||
|
)
|
||||||
|
for raw in ids:
|
||||||
await Notifications.insert_notification(session, {
|
await Notifications.insert_notification(session, {
|
||||||
"user_id": _uuid.UUID(str(raw)),
|
"user_id": _uuid.UUID(str(raw)),
|
||||||
"kind": "application",
|
"kind": "application",
|
||||||
"title": "CVs in the bank match this job",
|
"title": "CVs in the bank match this job",
|
||||||
"body": (
|
"body": body,
|
||||||
f"{count} stored CV{'s' if count != 1 else ''} look relevant to "
|
|
||||||
f"{job.title}. Open the CV Bank to review them."
|
|
||||||
),
|
|
||||||
"link_path": f"/cvbank?job={job_post_id}",
|
"link_path": f"/cvbank?job={job_post_id}",
|
||||||
"job_post_id": job.id,
|
"job_post_id": job.id,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
@ -485,23 +501,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
return updated
|
return updated
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
def _talent_pool_filters(cls, Users, *, search=None, job_post_ids=None):
|
||||||
"""Newest applications with a user + job for Talent Pool (manual / form)."""
|
filters = [cls.user_id.is_not(None), cls.job_post_id.is_not(None)]
|
||||||
from users.models import Users
|
|
||||||
|
|
||||||
if job_post_ids is not None and not list(job_post_ids):
|
|
||||||
return []
|
|
||||||
statement = (
|
|
||||||
select(cls)
|
|
||||||
.join(Users, cls.user_id == Users.id)
|
|
||||||
.where(cls.user_id.is_not(None), cls.job_post_id.is_not(None))
|
|
||||||
.order_by(cls.created_at.desc())
|
|
||||||
)
|
|
||||||
if job_post_ids is not None:
|
if job_post_ids is not None:
|
||||||
statement = statement.where(cls.job_post_id.in_(list(job_post_ids)))
|
filters.append(cls.job_post_id.in_(list(job_post_ids)))
|
||||||
if search:
|
if search:
|
||||||
like = f"%{search.strip()}%"
|
like = f"%{search.strip()}%"
|
||||||
statement = statement.where(
|
filters.append(
|
||||||
or_(
|
or_(
|
||||||
cls.candidate_name.ilike(like),
|
cls.candidate_name.ilike(like),
|
||||||
cls.candidate_email.ilike(like),
|
cls.candidate_email.ilike(like),
|
||||||
|
|
@ -509,10 +515,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
Users.email.ilike(like),
|
Users.email.ilike(like),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
statement = statement.limit(limit).offset(offset)
|
return filters
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def list_for_talent_pool(cls, session: AsyncSession, *, limit=100, offset=0, search=None, job_post_ids=None):
|
||||||
|
"""Newest applications with a user + assigned job for Candidates / Talent Pool."""
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return []
|
||||||
|
statement = (
|
||||||
|
select(cls)
|
||||||
|
.join(Users, cls.user_id == Users.id)
|
||||||
|
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||||
|
.order_by(cls.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
result = await session.execute(statement)
|
result = await session.execute(statement)
|
||||||
return list(result.scalars().all())
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def count_for_talent_pool(cls, session: AsyncSession, *, search=None, job_post_ids=None):
|
||||||
|
from users.models import Users
|
||||||
|
|
||||||
|
if job_post_ids is not None and not list(job_post_ids):
|
||||||
|
return 0
|
||||||
|
statement = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(cls)
|
||||||
|
.join(Users, cls.user_id == Users.id)
|
||||||
|
.where(*cls._talent_pool_filters(Users, search=search, job_post_ids=job_post_ids))
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||||
"""Newest platform/apply_via label per user — Candidates Form badges."""
|
"""Newest platform/apply_via label per user — Candidates Form badges."""
|
||||||
|
|
@ -646,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",
|
||||||
|
|
@ -673,8 +711,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
||||||
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
|
"""Speculative CVs (`apply_via=cv_bank`), including ones later linked to a job.
|
||||||
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
|
|
||||||
|
Run ATS assigns job_post_id so the row joins Pipeline like any other
|
||||||
|
application. The bank still lists them so the ATS score is visible on
|
||||||
|
this screen. Matching remains the assign queue for unassigned rows.
|
||||||
|
"""
|
||||||
|
bank = (cls.apply_via == "cv_bank",)
|
||||||
total = (
|
total = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(func.count()).select_from(cls).where(*bank)
|
select(func.count()).select_from(cls).where(*bank)
|
||||||
|
|
@ -720,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():
|
||||||
|
|
@ -732,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
|
||||||
|
|
@ -1101,6 +1151,38 @@ class Candidates(SQLModel, table=True):
|
||||||
})
|
})
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def latest_completed_by_emails(cls, session: AsyncSession, emails):
|
||||||
|
"""Newest completed ATS score per email, with the job it ran against.
|
||||||
|
|
||||||
|
CV Bank speculative rows have no candidates FK; email is the join the
|
||||||
|
scorer already writes (score_bank → upsert_candidate).
|
||||||
|
"""
|
||||||
|
from job.job_post.models import JobPosts
|
||||||
|
|
||||||
|
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||||
|
if not lowers:
|
||||||
|
return {}
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls, JobPosts.title)
|
||||||
|
.outerjoin(JobPosts, cls.job_id == JobPosts.id)
|
||||||
|
.where(func.lower(cls.candidate_email).in_(lowers))
|
||||||
|
.where(cls.status == "completed")
|
||||||
|
.where(cls.match_score.is_not(None))
|
||||||
|
.order_by(cls.updated_at.desc(), cls.created_at.desc())
|
||||||
|
)
|
||||||
|
out = {}
|
||||||
|
for rec, title in result.all():
|
||||||
|
email = (rec.candidate_email or "").strip().lower()
|
||||||
|
if not email or email in out:
|
||||||
|
continue
|
||||||
|
out[email] = {
|
||||||
|
"match_score": int(rec.match_score) if rec.match_score is not None else None,
|
||||||
|
"job_post_id": str(rec.job_id) if rec.job_id else None,
|
||||||
|
"job_title": title or rec.job_title or None,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||||
existing = None
|
existing = None
|
||||||
|
|
@ -1252,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
|
||||||
|
|
@ -1726,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()
|
||||||
|
|
@ -1750,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,18 @@ from job.activity.serializers import serialize_activity
|
||||||
from job.feedback.serializers import serialize_feedback
|
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):
|
||||||
|
if value in (None,""):
|
||||||
|
return None
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
def _id_list(value):
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
if isinstance(value,(list,tuple)):
|
||||||
|
return [str(v) for v in value if v not in (None,"")]
|
||||||
|
return [str(value)]
|
||||||
|
|
||||||
def serialize_candidate(row) -> dict:
|
def serialize_candidate(row) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
|
|
@ -72,11 +84,13 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
prefixed because the two sources have different key spaces and would
|
prefixed because the two sources have different key spaces and would
|
||||||
otherwise collide in a merged list.
|
otherwise collide in a merged list.
|
||||||
|
|
||||||
rank_score is the deterministic tier-1 overlap against whichever job the
|
rank_score is optional keyword overlap used by /cv-bank/suggestions, not
|
||||||
recruiter is ranking by; it is None until they pick one, and it is NOT an
|
by the CV Bank table. ai_score is filled after serialize by joining the
|
||||||
ATS score — ai_score is.
|
latest candidates row for this email — this function leaves it None.
|
||||||
|
Speculative uploads have no inbox suggestions; suggested_job_post_ids is [].
|
||||||
"""
|
"""
|
||||||
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
name=(row.candidate_name or "").strip() or (row.candidate_email or "").strip() or (row.file_name or "").strip() or "Unknown"
|
||||||
|
assigned=_id_str(row.job_post_id)
|
||||||
return {
|
return {
|
||||||
"id":f"bank:{row.id}",
|
"id":f"bank:{row.id}",
|
||||||
"record_id":str(row.id),
|
"record_id":str(row.id),
|
||||||
|
|
@ -90,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,
|
||||||
|
|
@ -99,7 +114,13 @@ def serialize_bank_candidate(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
"bank_reason":(row.bank_reason or "").strip() or None,
|
"bank_reason":(row.bank_reason or "").strip() or None,
|
||||||
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
"bank_expires_at":row.bank_expires_at.isoformat() if row.bank_expires_at else None,
|
||||||
"user_id":str(row.user_id) if row.user_id else None,
|
"user_id":str(row.user_id) if row.user_id else None,
|
||||||
"assigned_job_post_id":str(row.job_post_id) if row.job_post_id else None,
|
"message_id":None,
|
||||||
|
"assigned_job_post_id":assigned,
|
||||||
|
"assigned_job_title":None,
|
||||||
|
"scored_job_post_id":None,
|
||||||
|
"scored_job_title":None,
|
||||||
|
"suggested_job_post_ids":[],
|
||||||
|
"suggested_jobs":[],
|
||||||
"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,
|
||||||
}
|
}
|
||||||
|
|
@ -119,6 +140,9 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
name=(get("name") or "") or (get("email") or "") or "Unknown"
|
name=(get("name") or "") or (get("email") or "") or "Unknown"
|
||||||
expires=get("bank_expires_at")
|
expires=get("bank_expires_at")
|
||||||
created=get("created_at")
|
created=get("created_at")
|
||||||
|
assigned=_id_str(get("assigned_job_post_id") or get("last_job_post_id"))
|
||||||
|
suggested=_id_list(row.get("suggested_job_post_ids"))
|
||||||
|
last_title=get("last_job_title") or None
|
||||||
return {
|
return {
|
||||||
"id":f"app:{get('inbox_id')}",
|
"id":f"app:{get('inbox_id')}",
|
||||||
"record_id":str(get("inbox_id")),
|
"record_id":str(get("inbox_id")),
|
||||||
|
|
@ -132,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 []),
|
||||||
|
|
@ -139,11 +164,17 @@ def serialize_bank_silver_medalist(row, *, rank_score=None) -> Dict[str,Any]:
|
||||||
"ai_score":get("ai_score"),
|
"ai_score":get("ai_score"),
|
||||||
"recommendation":get("recommendation"),
|
"recommendation":get("recommendation"),
|
||||||
"rank_score":rank_score,
|
"rank_score":rank_score,
|
||||||
"last_job_title":get("last_job_title") or None,
|
"last_job_title":last_title,
|
||||||
"bank_reason":"silver_medalist",
|
"bank_reason":"silver_medalist",
|
||||||
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
||||||
"user_id":str(get("user_id")) if get("user_id") else None,
|
"user_id":str(get("user_id")) if get("user_id") else None,
|
||||||
"assigned_job_post_id":None,
|
"message_id":_id_str(get("message_id")),
|
||||||
|
"assigned_job_post_id":assigned,
|
||||||
|
"assigned_job_title":last_title,
|
||||||
|
"scored_job_post_id":assigned,
|
||||||
|
"scored_job_title":last_title,
|
||||||
|
"suggested_job_post_ids":suggested,
|
||||||
|
"suggested_jobs":[],
|
||||||
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
|
"created_at":created.isoformat() if hasattr(created,"isoformat") else created,
|
||||||
"updated_at":None,
|
"updated_at":None,
|
||||||
}
|
}
|
||||||
|
|
@ -272,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,
|
||||||
|
|
@ -288,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,
|
||||||
|
|
@ -313,6 +344,79 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_manual_candidate_list(profile: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""List shape of serialize_manual_candidate_profile — drop heavy detail."""
|
||||||
|
return {
|
||||||
|
"inbox_id": None,
|
||||||
|
"manual_upload_candidate_id": profile.get("manual_upload_candidate_id"),
|
||||||
|
"user_id": profile.get("user_id"),
|
||||||
|
"candidate_id": None,
|
||||||
|
"name": profile.get("name"),
|
||||||
|
"email": profile.get("email"),
|
||||||
|
"is_active": profile.get("is_active"),
|
||||||
|
"message_id": None,
|
||||||
|
"created_at": profile.get("created_at"),
|
||||||
|
"application_status": profile.get("application_status"),
|
||||||
|
"experience": profile.get("experience"),
|
||||||
|
"current_employment": profile.get("current_employment"),
|
||||||
|
"current_title": profile.get("current_title"),
|
||||||
|
"resume_text": None,
|
||||||
|
"suggested_job_post_ids": profile.get("suggested_job_post_ids") or [],
|
||||||
|
"assigned_job_post_id": profile.get("assigned_job_post_id"),
|
||||||
|
"job_posts": profile.get("job_posts") or [],
|
||||||
|
"assigned_job_post": profile.get("assigned_job_post"),
|
||||||
|
"job_title": profile.get("job_title"),
|
||||||
|
"recruiter": profile.get("recruiter"),
|
||||||
|
"recruiter_id": profile.get("recruiter_id"),
|
||||||
|
"source": profile.get("source"),
|
||||||
|
"file_path": profile.get("file_path"),
|
||||||
|
"ai_score": None,
|
||||||
|
"recommendation": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_form_candidate_list(row) -> Dict[str, Any]:
|
||||||
|
"""GET /candidate/fetch list row for an unpromoted FormData application.
|
||||||
|
|
||||||
|
processing_state is an inbox tab, not Candidate_application_Status, so it
|
||||||
|
is not copied onto application_status — CLOSED/rejected-tab values would
|
||||||
|
paint every sheet row as Rejected on Candidates.
|
||||||
|
"""
|
||||||
|
assigned = row.assigned_job_post_id or row.job_post_id
|
||||||
|
suggested = [str(v) for v in (row.suggested_job_post_ids or []) if v not in (None, "")]
|
||||||
|
created = row.created_at.isoformat() if row.created_at else None
|
||||||
|
name = (row.name or "").strip() or None
|
||||||
|
email = (row.candidate_email or "").strip() or None
|
||||||
|
return {
|
||||||
|
"inbox_id": None,
|
||||||
|
"form_data_id": str(row.id),
|
||||||
|
"manual_upload_candidate_id": None,
|
||||||
|
"user_id": None,
|
||||||
|
"candidate_id": None,
|
||||||
|
"name": name,
|
||||||
|
"email": email,
|
||||||
|
"is_active": None,
|
||||||
|
"message_id": None,
|
||||||
|
"created_at": created,
|
||||||
|
"application_status": None,
|
||||||
|
"experience": (row.experience or "").strip() or None,
|
||||||
|
"current_employment": (row.current_company or "").strip() or None,
|
||||||
|
"current_title": (row.position_applied_for or "").strip() or None,
|
||||||
|
"resume_text": None,
|
||||||
|
"suggested_job_post_ids": suggested,
|
||||||
|
"assigned_job_post_id": str(assigned) if assigned else None,
|
||||||
|
"job_posts": [],
|
||||||
|
"assigned_job_post": None,
|
||||||
|
"job_title": (row.position_applied_for or "").strip() or None,
|
||||||
|
"recruiter": None,
|
||||||
|
"recruiter_id": None,
|
||||||
|
"source": "Form",
|
||||||
|
"file_path": None,
|
||||||
|
"ai_score": None,
|
||||||
|
"recommendation": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def serialize_manager_candidate(row, *, source) -> dict:
|
def serialize_manager_candidate(row, *, source) -> dict:
|
||||||
"""One application on a hiring-manager's job — list row, not the profile."""
|
"""One application on a hiring-manager's job — list row, not the profile."""
|
||||||
inbox_id = row.get("inbox_id")
|
inbox_id = row.get("inbox_id")
|
||||||
|
|
@ -347,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
|
||||||
|
|
@ -360,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.
|
||||||
|
|
||||||
|
|
@ -406,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,
|
||||||
|
|
@ -416,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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_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)
|
||||||
|
|
@ -1212,19 +1237,27 @@ class CandidateView:
|
||||||
await assert_manager_candidate_access(
|
await assert_manager_candidate_access(
|
||||||
self.session,current_user,user_id=user_id,created_by=created_by,
|
self.session,current_user,user_id=user_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
detail=bool(user_id)
|
return await self._get_candidate_detail(
|
||||||
list_job_ids=None
|
user_id,limit=limit,offset=offset,search=search,
|
||||||
if not detail:
|
current_user=current_user,created_by=created_by,
|
||||||
|
)
|
||||||
list_job_ids=await job_post_ids_for_candidate_list(
|
list_job_ids=await job_post_ids_for_candidate_list(
|
||||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
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(
|
||||||
|
limit=limit,offset=offset,search=search,job_post_ids=list_job_ids,assignment=assignment,
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
async def _get_candidate_detail(self,user_id,limit=10,offset=0,search=None,current_user=None,created_by=False):
|
||||||
rows=await Inbox.get_candidate_profile(
|
rows=await Inbox.get_candidate_profile(
|
||||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||||
job_post_ids=list_job_ids,
|
|
||||||
)
|
)
|
||||||
if detail:
|
|
||||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||||
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
||||||
if records and owned is not None:
|
if records and owned is not None:
|
||||||
|
|
@ -1240,8 +1273,11 @@ class CandidateView:
|
||||||
records=[]
|
records=[]
|
||||||
if records:
|
if records:
|
||||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
return await self._get_manual_detail(user_id,owned,current_user)
|
||||||
# row — resolve the profile from that table instead of returning [].
|
|
||||||
|
async def _get_manual_detail(self,user_id,owned,current_user):
|
||||||
|
from inbox.plugins import get_ats_score_for_manual_user
|
||||||
|
|
||||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||||
if not manual:
|
if not manual:
|
||||||
return []
|
return []
|
||||||
|
|
@ -1254,7 +1290,6 @@ class CandidateView:
|
||||||
if manual.job_post_id:
|
if manual.job_post_id:
|
||||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
||||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
||||||
from inbox.plugins import get_ats_score_for_manual_user
|
|
||||||
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||||
if score:
|
if score:
|
||||||
payload["ai_score"]=score["overall_score"]
|
payload["ai_score"]=score["overall_score"]
|
||||||
|
|
@ -1263,85 +1298,100 @@ class CandidateView:
|
||||||
payload["candidate_id"]=score.get("candidate_id")
|
payload["candidate_id"]=score.get("candidate_id")
|
||||||
if score.get("user_id") and not payload.get("user_id"):
|
if score.get("user_id") and not payload.get("user_id"):
|
||||||
payload["user_id"]=score["user_id"]
|
payload["user_id"]=score["user_id"]
|
||||||
if score.get("job_post_id"):
|
if score and score.get("job_post_id"):
|
||||||
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)
|
||||||
# List mode: inbox applications + manual/form applications (dedupe by user).
|
|
||||||
|
async def _list_candidates(self,limit=10,offset=0,search=None,job_post_ids=None,assignment=None):
|
||||||
|
rows=await Inbox.get_candidate_profile(
|
||||||
|
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):
|
||||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
|
||||||
self.session,limit=limit,offset=0,search=search,job_post_ids=list_job_ids,
|
|
||||||
)
|
|
||||||
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")}
|
||||||
manual_payloads=[]
|
seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()}
|
||||||
for manual in manual_rows:
|
manual_payloads=await self._list_manual_payloads(
|
||||||
uid=str(manual.user_id) if manual.user_id else None
|
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(
|
||||||
|
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)
|
||||||
|
|
||||||
|
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(
|
||||||
|
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,
|
||||||
|
)
|
||||||
|
payloads=[]
|
||||||
|
for row in rows:
|
||||||
|
uid=str(row.user_id) if row.user_id else None
|
||||||
if uid and uid in seen:
|
if uid and uid in seen:
|
||||||
continue
|
continue
|
||||||
user=await Users.get_user_by_id(self.session,manual.user_id) if manual.user_id else None
|
user=await Users.get_user_by_id(self.session,row.user_id) if row.user_id else None
|
||||||
job_post=None
|
job_post=None
|
||||||
if manual.job_post_id:
|
if row.job_post_id:
|
||||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(manual.job_post_id))
|
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
|
||||||
payload=serialize_manual_candidate_profile(manual,user,job_post)
|
payload=serialize_manual_candidate_list(serialize_manual_candidate_profile(row,user,job_post))
|
||||||
# List shape matches attach_job_posts: keep job_posts, drop heavy detail.
|
payloads.append(payload)
|
||||||
manual_payloads.append({
|
|
||||||
"inbox_id":None,
|
|
||||||
"manual_upload_candidate_id":payload["manual_upload_candidate_id"],
|
|
||||||
"user_id":payload["user_id"],
|
|
||||||
"candidate_id":None,
|
|
||||||
"name":payload["name"],
|
|
||||||
"email":payload["email"],
|
|
||||||
"is_active":payload.get("is_active"),
|
|
||||||
"message_id":None,
|
|
||||||
"created_at":payload.get("created_at"),
|
|
||||||
"application_status":payload.get("application_status"),
|
|
||||||
"experience":payload.get("experience"),
|
|
||||||
"current_employment":payload.get("current_employment"),
|
|
||||||
"current_title":payload.get("current_title"),
|
|
||||||
"resume_text":None,
|
|
||||||
"suggested_job_post_ids":[],
|
|
||||||
"assigned_job_post_id":payload.get("assigned_job_post_id"),
|
|
||||||
"job_posts":payload.get("job_posts") or [],
|
|
||||||
"assigned_job_post":payload.get("assigned_job_post"),
|
|
||||||
"job_title":payload.get("job_title"),
|
|
||||||
"recruiter":payload.get("recruiter"),
|
|
||||||
"recruiter_id":payload.get("recruiter_id"),
|
|
||||||
"source":payload.get("source"),
|
|
||||||
"file_path":payload.get("file_path"),
|
|
||||||
"ai_score":None,
|
|
||||||
"recommendation":None,
|
|
||||||
})
|
|
||||||
if uid:
|
if uid:
|
||||||
seen.add(uid)
|
seen.add(uid)
|
||||||
|
email=(payload.get("email") or "").strip().lower()
|
||||||
|
if email:
|
||||||
|
seen_emails.add(email)
|
||||||
|
await self._attach_user_ats(payloads)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
async def _list_form_payloads(self,limit,search,job_post_ids,seen_emails,assignment=None):
|
||||||
|
rows=await FormData.list_for_talent_pool(
|
||||||
|
self.session,limit=limit,offset=0,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||||
|
)
|
||||||
|
payloads=[]
|
||||||
|
for rec in rows:
|
||||||
|
payload=serialize_form_candidate_list(rec)
|
||||||
|
email=(payload.get("email") or "").strip().lower()
|
||||||
|
if email and email in seen_emails:
|
||||||
|
continue
|
||||||
|
payloads.append(payload)
|
||||||
|
if email:
|
||||||
|
seen_emails.add(email)
|
||||||
|
payloads=await self._hydrate_list_jobs(payloads)
|
||||||
|
await self._attach_form_ats(payloads)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
async def _attach_user_ats(self,payloads):
|
||||||
from inbox.plugins import get_ats_scores_for_users
|
from inbox.plugins import get_ats_scores_for_users
|
||||||
owners=[p.get("user_id") for p in manual_payloads if p.get("user_id")]
|
owners=[p.get("user_id") for p in payloads if p.get("user_id")]
|
||||||
ats=await get_ats_scores_for_users(self.session,owners)
|
ats=await get_ats_scores_for_users(self.session,owners)
|
||||||
for payload in manual_payloads:
|
for payload in payloads:
|
||||||
row=ats.get(str(payload.get("user_id") or ""))
|
row=ats.get(str(payload.get("user_id") or ""))
|
||||||
if row and row.get("overall_score") is not None:
|
if row and row.get("overall_score") is not None:
|
||||||
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"])
|
||||||
data=inbox_payloads+manual_payloads
|
|
||||||
if assigned_job_post_id:
|
|
||||||
job_id=str(assigned_job_post_id)
|
|
||||||
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
|
|
||||||
return await self.attach_application_history(data)
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
|
||||||
|
|
||||||
async def count_candidates(self,user_id=None,search=None,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:
|
||||||
job_post_ids=await job_post_ids_for_candidate_list(
|
job_post_ids=await job_post_ids_for_candidate_list(
|
||||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
return await Inbox.count_candidate_profiles(
|
if job_post_ids is not None and not job_post_ids:
|
||||||
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
|
return 0
|
||||||
|
inbox_n=await Inbox.count_candidate_profiles(
|
||||||
|
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||||
)
|
)
|
||||||
|
if user_id:
|
||||||
|
return inbox_n
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
form_n=await FormData.count_for_talent_pool(
|
||||||
|
self.session,search=search,job_post_ids=job_post_ids,assignment=assignment,
|
||||||
|
)
|
||||||
|
return inbox_n+manual_n+form_n
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1410,6 +1460,47 @@ class CandidateView:
|
||||||
data["job_title"]=payload.get("title")
|
data["job_title"]=payload.get("title")
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
async def _hydrate_list_jobs(self,payloads):
|
||||||
|
"""Attach assigned + suggested job posts onto already-serialized list rows."""
|
||||||
|
wanted=[]
|
||||||
|
for payload in payloads:
|
||||||
|
if payload.get("assigned_job_post_id"):
|
||||||
|
wanted.append(payload["assigned_job_post_id"])
|
||||||
|
wanted.extend(payload.get("suggested_job_post_ids") or [])
|
||||||
|
posts=await self._job_posts_by_id(wanted)
|
||||||
|
for payload in payloads:
|
||||||
|
assigned_id=payload.get("assigned_job_post_id")
|
||||||
|
if assigned_id:
|
||||||
|
post=posts.get(str(assigned_id))
|
||||||
|
self._attach_job_post(payload,dict(post) if post else None,as_assigned=True)
|
||||||
|
for job_id in payload.get("suggested_job_post_ids") or []:
|
||||||
|
if assigned_id and str(job_id)==str(assigned_id):
|
||||||
|
continue
|
||||||
|
post=posts.get(str(job_id))
|
||||||
|
self._attach_job_post(payload,dict(post) if post else None)
|
||||||
|
return payloads
|
||||||
|
|
||||||
|
async def _attach_form_ats(self,payloads):
|
||||||
|
grouped=await AtsResults.get_current_for_forms(
|
||||||
|
self.session,[p.get("form_data_id") for p in payloads],
|
||||||
|
)
|
||||||
|
for payload in payloads:
|
||||||
|
fid=payload.get("form_data_id")
|
||||||
|
try:
|
||||||
|
key=uuid.UUID(str(fid)) if fid else None
|
||||||
|
except (TypeError,ValueError):
|
||||||
|
key=None
|
||||||
|
rows=grouped.get(key) or [] if key is not None else []
|
||||||
|
assigned=payload.get("assigned_job_post_id")
|
||||||
|
chosen=None
|
||||||
|
if assigned:
|
||||||
|
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
|
||||||
|
if chosen is None and rows:
|
||||||
|
chosen=rows[0]
|
||||||
|
if chosen is not None and chosen.overall_score is not None:
|
||||||
|
payload["ai_score"]=chosen.overall_score
|
||||||
|
payload["recommendation"]=chosen.band or self._recommendation(chosen.overall_score)
|
||||||
|
|
||||||
async def _job_posts_by_id(self,ids):
|
async def _job_posts_by_id(self,ids):
|
||||||
"""Serialized job posts keyed by id — one query for a whole page of rows.
|
"""Serialized job posts keyed by id — one query for a whole page of rows.
|
||||||
|
|
||||||
|
|
@ -1651,9 +1742,8 @@ class CandidateView:
|
||||||
band=None,job_post_id=None,limit=50,offset=0):
|
band=None,job_post_id=None,limit=50,offset=0):
|
||||||
"""The unified CV Bank: speculative uploads plus scored rejections.
|
"""The unified CV Bank: speculative uploads plus scored rejections.
|
||||||
|
|
||||||
job_post_id does not filter — it attaches the tier-1 rank_score for
|
job_post_id does not filter — it is only used by /cv-bank/suggestions
|
||||||
that job and sorts by it, which is how a recruiter "pulls from" the
|
to attach rank_score. The CV Bank screen itself no longer ranks.
|
||||||
bank when an opening appears.
|
|
||||||
"""
|
"""
|
||||||
from inbox.models import Inbox
|
from inbox.models import Inbox
|
||||||
from job.candidate.serializers import serialize_bank_candidate,serialize_bank_silver_medalist
|
from job.candidate.serializers import serialize_bank_candidate,serialize_bank_silver_medalist
|
||||||
|
|
@ -1670,6 +1760,8 @@ class CandidateView:
|
||||||
)
|
)
|
||||||
rows.extend(serialize_bank_silver_medalist(r) for r in medalists)
|
rows.extend(serialize_bank_silver_medalist(r) for r in medalists)
|
||||||
|
|
||||||
|
rows=await self._attach_bank_ats(rows)
|
||||||
|
rows=await self._hydrate_bank_job_titles(rows)
|
||||||
if job_post_id:
|
if job_post_id:
|
||||||
rows=await self._attach_rank_scores(rows,job_post_id)
|
rows=await self._attach_rank_scores(rows,job_post_id)
|
||||||
|
|
||||||
|
|
@ -1685,6 +1777,68 @@ class CandidateView:
|
||||||
|
|
||||||
return int(os.getenv("CV_BANK_SILVER_FLOOR","60"))
|
return int(os.getenv("CV_BANK_SILVER_FLOOR","60"))
|
||||||
|
|
||||||
|
async def _attach_bank_ats(self,rows):
|
||||||
|
"""Join the latest completed ATS score onto speculative bank rows.
|
||||||
|
|
||||||
|
serialize_bank_candidate leaves ai_score None; scores live on
|
||||||
|
candidates (and ats_results) keyed by email, written by score_bank.
|
||||||
|
Silver medalists already carry the inbox denorm score.
|
||||||
|
"""
|
||||||
|
emails=[]
|
||||||
|
for row in rows:
|
||||||
|
if row.get("bank_source")!="speculative":
|
||||||
|
continue
|
||||||
|
email=(row.get("email") or "").strip().lower()
|
||||||
|
if email:
|
||||||
|
emails.append(email)
|
||||||
|
if not emails:
|
||||||
|
return rows
|
||||||
|
latest=await Candidates.latest_completed_by_emails(self.session,emails)
|
||||||
|
for row in rows:
|
||||||
|
if row.get("bank_source")!="speculative":
|
||||||
|
continue
|
||||||
|
hit=latest.get((row.get("email") or "").strip().lower())
|
||||||
|
if not hit:
|
||||||
|
continue
|
||||||
|
row["ai_score"]=hit.get("match_score")
|
||||||
|
row["recommendation"]=self._recommendation(hit.get("match_score"))
|
||||||
|
row["scored_job_post_id"]=hit.get("job_post_id")
|
||||||
|
row["scored_job_title"]=hit.get("job_title")
|
||||||
|
return rows
|
||||||
|
|
||||||
|
async def _hydrate_bank_job_titles(self,rows):
|
||||||
|
"""Resolve assigned / scored / suggested job ids to titles in one fetch."""
|
||||||
|
ids=[]
|
||||||
|
seen=set()
|
||||||
|
def add(jid):
|
||||||
|
key=str(jid) if jid not in (None,"") else ""
|
||||||
|
if key and key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
ids.append(key)
|
||||||
|
for row in rows:
|
||||||
|
add(row.get("assigned_job_post_id"))
|
||||||
|
add(row.get("scored_job_post_id"))
|
||||||
|
for jid in row.get("suggested_job_post_ids") or []:
|
||||||
|
add(jid)
|
||||||
|
titles={}
|
||||||
|
if ids:
|
||||||
|
posts=await JobPosts.titles_by_ids(self.session,ids,active_only=False)
|
||||||
|
titles={str(p.id):p.title for p in posts}
|
||||||
|
for row in rows:
|
||||||
|
assigned=str(row.get("assigned_job_post_id") or "")
|
||||||
|
scored=str(row.get("scored_job_post_id") or "")
|
||||||
|
if not row.get("assigned_job_title"):
|
||||||
|
row["assigned_job_title"]=titles.get(assigned)
|
||||||
|
if not row.get("scored_job_title"):
|
||||||
|
row["scored_job_title"]=titles.get(scored)
|
||||||
|
suggested=[]
|
||||||
|
for jid in row.get("suggested_job_post_ids") or []:
|
||||||
|
title=titles.get(str(jid))
|
||||||
|
if title:
|
||||||
|
suggested.append({"id":str(jid),"title":title})
|
||||||
|
row["suggested_jobs"]=suggested
|
||||||
|
return rows
|
||||||
|
|
||||||
async def _attach_rank_scores(self,rows,job_post_id):
|
async def _attach_rank_scores(self,rows,job_post_id):
|
||||||
"""Fill rank_score from the stored tier-1 ranking for one job.
|
"""Fill rank_score from the stored tier-1 ranking for one job.
|
||||||
|
|
||||||
|
|
@ -1854,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 [])
|
||||||
|
|
@ -1870,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 [])
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"] == ""
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -72,9 +72,18 @@ const rejected = toApplicationListView({
|
||||||
created_at: '2026-09-01T10:00:00Z',
|
created_at: '2026-09-01T10:00:00Z',
|
||||||
})
|
})
|
||||||
ok('REJECTED maps to Rejected stage', rejected.stage === 'Rejected', `stage=${rejected.stage}`)
|
ok('REJECTED maps to Rejected stage', rejected.stage === 'Rejected', `stage=${rejected.stage}`)
|
||||||
ok('CLOSED also reads as Rejected', toApplicationListView({
|
ok('CLOSED shows as CLOSED', toApplicationListView({
|
||||||
inbox_id: 43, application_status: 'CLOSED', name: 'Closed',
|
inbox_id: 43, application_status: 'CLOSED', name: 'Closed',
|
||||||
}).stage === 'Rejected')
|
}).stage === 'CLOSED')
|
||||||
|
|
||||||
|
const formRow = toApplicationListView({
|
||||||
|
form_data_id: 'ffffffff-ffff-ffff-ffff-ffffffffffff',
|
||||||
|
name: 'Form Applicant',
|
||||||
|
source: 'Form',
|
||||||
|
job_title: 'Brand Manager',
|
||||||
|
})
|
||||||
|
ok('form row key', formRow.id === 'form:ffffffff-ffff-ffff-ffff-ffffffffffff')
|
||||||
|
ok('form with no pipeline status has no stage', formRow.stage == null)
|
||||||
|
|
||||||
const hired = toApplicationListView({
|
const hired = toApplicationListView({
|
||||||
inbox_id: 44,
|
inbox_id: 44,
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
@ -31,14 +31,14 @@ export function funnel({ fromDate, toDate, department, recruiterId } = {}) {
|
||||||
/** Board column order used by the pipeline page. Rejected is last so callers
|
/** Board column order used by the pipeline page. Rejected is last so callers
|
||||||
* that drop outcomes can slice it off without re-sorting. */
|
* that drop outcomes can slice it off without re-sorting. */
|
||||||
const BOARD_STAGE_ORDER = [
|
const BOARD_STAGE_ORDER = [
|
||||||
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
'CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||||
'Approved', 'Hired', 'On Hold', 'Rejected',
|
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
|
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
|
||||||
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
|
* columns. Same mapping the board uses, so CLOSED stays CLOSED and only
|
||||||
* APPROVED keep their own columns rather than folding into Screening / Hired.
|
* REJECTED is Rejected. ONHOLD / APPROVED keep their own columns.
|
||||||
*/
|
*/
|
||||||
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
|
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
|
||||||
const folded = toStageCounts(
|
const folded = toStageCounts(
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,9 +59,8 @@ export function uploadToCvBank(file) {
|
||||||
* The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list:
|
* The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list:
|
||||||
* speculative uploads with no job, and rejected applicants who scored well.
|
* speculative uploads with no job, and rejected applicants who scored well.
|
||||||
*
|
*
|
||||||
* `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword
|
* `jobPostId` is unused by the CV Bank screen. The suggestions endpoint still
|
||||||
* overlap against that job) and sorts by it — the "a role just opened, who do
|
* passes it to attach rank_score for notifications on job create.
|
||||||
* we already have" view.
|
|
||||||
*/
|
*/
|
||||||
export function listCvBank({
|
export function listCvBank({
|
||||||
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
|
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
|
||||||
|
|
@ -238,6 +237,12 @@ function statusKey(value) {
|
||||||
return String(value).toUpperCase()
|
return String(value).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listStageOf(status) {
|
||||||
|
const key = statusKey(status)
|
||||||
|
if (!key) return null
|
||||||
|
return STAGE_FROM_STATUS[key] ?? 'Shortlist'
|
||||||
|
}
|
||||||
|
|
||||||
function bandOf(score, recommendation) {
|
function bandOf(score, recommendation) {
|
||||||
if (recommendation) return recommendation
|
if (recommendation) return recommendation
|
||||||
if (score == null || !Number.isFinite(Number(score))) return null
|
if (score == null || !Number.isFinite(Number(score))) return null
|
||||||
|
|
@ -261,12 +266,15 @@ export function toApplicationListView(row) {
|
||||||
const rawScore = row.ai_score ?? row.match_score
|
const rawScore = row.ai_score ?? row.match_score
|
||||||
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
|
const aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
|
||||||
const score = Number.isFinite(aiScore) ? aiScore : null
|
const score = Number.isFinite(aiScore) ? aiScore : null
|
||||||
return {
|
const id = row.inbox_id != null
|
||||||
id: row.inbox_id != null
|
|
||||||
? `inbox:${row.inbox_id}`
|
? `inbox:${row.inbox_id}`
|
||||||
: (row.manual_upload_candidate_id
|
: (row.manual_upload_candidate_id
|
||||||
? `manual:${row.manual_upload_candidate_id}`
|
? `manual:${row.manual_upload_candidate_id}`
|
||||||
: String(row.user_id || row.id || name)),
|
: (row.form_data_id
|
||||||
|
? `form:${row.form_data_id}`
|
||||||
|
: String(row.user_id || row.id || name)))
|
||||||
|
return {
|
||||||
|
id,
|
||||||
userId: row.user_id || null,
|
userId: row.user_id || null,
|
||||||
name,
|
name,
|
||||||
email: row.email ?? null,
|
email: row.email ?? null,
|
||||||
|
|
@ -274,11 +282,15 @@ export function toApplicationListView(row) {
|
||||||
jobTitle,
|
jobTitle,
|
||||||
recruiter: row.recruiter || null,
|
recruiter: row.recruiter || null,
|
||||||
applicationStatus: status || null,
|
applicationStatus: status || null,
|
||||||
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
|
stage: listStageOf(row.application_status ?? row.stage),
|
||||||
source: row.source || null,
|
source: row.source || null,
|
||||||
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 : [],
|
||||||
}
|
}
|
||||||
|
|
@ -289,30 +301,50 @@ export const BANK_SOURCE_LABELS = {
|
||||||
silver_medalist: 'Silver medalist',
|
silver_medalist: 'Silver medalist',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asJobList(value) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
const out = []
|
||||||
|
for (const item of value) {
|
||||||
|
if (item && typeof item === 'object') {
|
||||||
|
const id = item.id != null ? String(item.id) : ''
|
||||||
|
if (id) out.push({ id, title: item.title || '' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item != null && item !== '') out.push({ id: String(item), title: '' })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /candidate/cv-bank/fetch row -> the CV Bank table.
|
* GET /candidate/cv-bank/fetch row -> the CV Bank table.
|
||||||
*
|
*
|
||||||
* Two numbers that must never be confused: `aiScore` is a real paid ATS score
|
* `aiScore` is a real paid ATS score and only exists once someone ran one.
|
||||||
* and only exists once someone ran one; `rankScore` is free keyword overlap
|
* Suggested jobs come from inbox silver-medalist payloads; speculative rows
|
||||||
* against whichever job is selected. The screen renders them differently on
|
* typically have none. `scoredJobPostId` is the job the last ATS ran against.
|
||||||
* purpose.
|
|
||||||
*/
|
*/
|
||||||
export function toBankRowView(row) {
|
export function toBankRowView(row) {
|
||||||
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
|
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
|
||||||
const aiScore = Number.isFinite(score) ? score : null
|
const aiScore = Number.isFinite(score) ? score : null
|
||||||
const rank = row.rank_score == null || row.rank_score === '' ? null : Number(row.rank_score)
|
|
||||||
const years = row.years_experience == null || row.years_experience === ''
|
const years = row.years_experience == null || row.years_experience === ''
|
||||||
? null
|
? null
|
||||||
: Number(row.years_experience)
|
: Number(row.years_experience)
|
||||||
const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null
|
const expires = row.bank_expires_at ? new Date(row.bank_expires_at) : null
|
||||||
|
const suggestedJobs = row.suggested_jobs?.length
|
||||||
|
? asJobList(row.suggested_jobs)
|
||||||
|
: asJobList(row.suggested_job_post_ids)
|
||||||
|
const assignedJobPostId = row.assigned_job_post_id ? String(row.assigned_job_post_id) : null
|
||||||
|
const scoredJobPostId = row.scored_job_post_id
|
||||||
|
? String(row.scored_job_post_id)
|
||||||
|
: assignedJobPostId
|
||||||
|
const isStoredCv = row.bank_source !== 'silver_medalist'
|
||||||
return {
|
return {
|
||||||
id: String(row.id || ''),
|
id: String(row.id || ''),
|
||||||
recordId: row.record_id != null ? String(row.record_id) : null,
|
recordId: row.record_id != null ? String(row.record_id) : null,
|
||||||
source: row.bank_source || 'speculative',
|
source: row.bank_source || 'speculative',
|
||||||
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
|
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
|
||||||
// A silver medalist is read live from their application, so removing or
|
// A silver medalist is read live from their application, so removing or
|
||||||
// re-scoring them is not the bank's call to make.
|
// re-scoring them via the bank score endpoint is not the bank's call.
|
||||||
isStoredCv: row.bank_source !== 'silver_medalist',
|
isStoredCv,
|
||||||
name: row.name || row.email || 'Unknown',
|
name: row.name || row.email || 'Unknown',
|
||||||
email: row.email ?? null,
|
email: row.email ?? null,
|
||||||
phone: row.phone ?? null,
|
phone: row.phone ?? null,
|
||||||
|
|
@ -322,11 +354,18 @@ 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,
|
||||||
recommendation: bandOf(aiScore, row.recommendation || null),
|
recommendation: bandOf(aiScore, row.recommendation || null),
|
||||||
rankScore: Number.isFinite(rank) ? rank : null,
|
suggestedJobs,
|
||||||
|
scoredJobPostId,
|
||||||
|
scoredJobTitle: row.scored_job_title || row.assigned_job_title || null,
|
||||||
|
assignedJobPostId,
|
||||||
|
assignedJobTitle: row.assigned_job_title || null,
|
||||||
|
messageId: row.message_id ? String(row.message_id) : null,
|
||||||
|
canRunAts: isStoredCv || Boolean(row.message_id),
|
||||||
lastJobTitle: row.last_job_title ?? null,
|
lastJobTitle: row.last_job_title ?? null,
|
||||||
bankReason: row.bank_reason ?? null,
|
bankReason: row.bank_reason ?? null,
|
||||||
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,
|
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires : null,
|
||||||
|
|
@ -358,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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,8 @@ import { toDate } from '../lib/format'
|
||||||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||||
*
|
*
|
||||||
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
* The enum has 11 values. Approved and On Hold are first-class columns (they
|
||||||
* used to be folded into Hired / Screening). CLOSED is the inbox default for
|
* used to be folded into Hired / Screening). CLOSED is the inbox default and
|
||||||
* an application that did not progress — it reads as Rejected, same as the
|
* is shown as CLOSED. Only REJECTED reads as Rejected.
|
||||||
* board's Rejected column, not as Shortlist.
|
|
||||||
*
|
*
|
||||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||||
* board — a card with no column is a candidate nobody sees.
|
* board — a card with no column is a candidate nobody sees.
|
||||||
|
|
@ -34,7 +33,7 @@ export const STAGE_FROM_STATUS = {
|
||||||
OFFER: 'Offer',
|
OFFER: 'Offer',
|
||||||
APPROVED: 'Approved',
|
APPROVED: 'Approved',
|
||||||
HIRED: 'Hired',
|
HIRED: 'Hired',
|
||||||
CLOSED: 'Rejected',
|
CLOSED: 'CLOSED',
|
||||||
REJECTED: 'Rejected',
|
REJECTED: 'Rejected',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +45,7 @@ export const STAGE_FROM_STATUS = {
|
||||||
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
||||||
*/
|
*/
|
||||||
export const STATUS_FROM_STAGE = {
|
export const STATUS_FROM_STAGE = {
|
||||||
|
CLOSED: 'CLOSED',
|
||||||
Shortlist: 'PENDING',
|
Shortlist: 'PENDING',
|
||||||
Screening: 'SCREENING',
|
Screening: 'SCREENING',
|
||||||
'On Hold': 'ONHOLD',
|
'On Hold': 'ONHOLD',
|
||||||
|
|
@ -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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ const SOURCE_LABEL = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGE_BADGE = {
|
const STAGE_BADGE = {
|
||||||
|
CLOSED: 'b-gray',
|
||||||
Shortlist: 'b-indigo',
|
Shortlist: 'b-indigo',
|
||||||
Screening: 'b-teal',
|
Screening: 'b-teal',
|
||||||
Assessment: 'b-purple',
|
Assessment: 'b-purple',
|
||||||
|
|
@ -65,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. */
|
||||||
|
|
@ -98,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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,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) {
|
||||||
|
|
@ -177,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()}
|
||||||
|
|
@ -189,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`
|
||||||
|
|
@ -198,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) {
|
||||||
|
|
@ -246,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,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { companies, moneyK, pick } from '../data/seed'
|
||||||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
||||||
// Forward progression for the live Advance button. Rejected has no next stage.
|
// Forward progression for the live Advance button. Rejected has no next stage.
|
||||||
const KANBAN_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
const KANBAN_ORDER = ['CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Approved', 'Hired']
|
||||||
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
const LABEL = { fontSize: 12, color: 'var(--text-3)', fontWeight: 600, textTransform: 'uppercase', marginBottom: 8 }
|
||||||
|
|
||||||
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
Candidates — applications on live backend data.
|
Candidates — applications on live backend data.
|
||||||
|
|
||||||
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
Recruiter rows come from GET /candidate/fetch (inbox + manual + form), one row
|
||||||
per application, so score / stage / job / recruiter have a source.
|
per application, so score / stage / job / recruiter have a source.
|
||||||
Without candidates.manage the server scopes that list to jobs the user
|
Without candidates.manage the server scopes that list to jobs the user
|
||||||
owns as recruiter (or created). Tick Requisitions → Configure in Access
|
owns as recruiter (or created). Tick Requisitions → Configure in Access
|
||||||
|
|
@ -36,9 +36,9 @@ 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 = ['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']
|
||||||
const BAND_BADGE = {
|
const BAND_BADGE = {
|
||||||
'Strong Match': 'b-green',
|
'Strong Match': 'b-green',
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -136,6 +137,7 @@ const REFERRAL_RE = new RegExp(
|
||||||
const referralValue = (raw) => (raw || '').trim().toLowerCase()
|
const referralValue = (raw) => (raw || '').trim().toLowerCase()
|
||||||
|
|
||||||
const STAGE_BADGE = {
|
const STAGE_BADGE = {
|
||||||
|
CLOSED: 'b-gray',
|
||||||
Shortlist: 'b-indigo',
|
Shortlist: 'b-indigo',
|
||||||
Screening: 'b-teal',
|
Screening: 'b-teal',
|
||||||
Assessment: 'b-purple',
|
Assessment: 'b-purple',
|
||||||
|
|
@ -323,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({
|
||||||
|
|
@ -399,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
|
||||||
|
|
@ -608,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>
|
||||||
|
|
|
||||||
|
|
@ -10,19 +10,14 @@
|
||||||
job. Read live from their application rather than copied
|
job. Read live from their application rather than copied
|
||||||
here, so there is one source of truth and nothing to sync.
|
here, so there is one source of truth and nothing to sync.
|
||||||
|
|
||||||
Two very different numbers live on this screen and must not be confused:
|
ATS is a real paid score. Each row picks a job, then Run ATS scores that
|
||||||
|
one candidate. Speculative rows are assigned to the job first (so the banked
|
||||||
Match free, deterministic keyword overlap against the job picked in
|
CV links like any other application), then scored. Silver medalists stay on
|
||||||
"Rank against job". It orders the pile. It is not an assessment.
|
their inbox application and score via score_inbox when a message id exists.
|
||||||
ATS a real paid score, and only present once someone ran one. The
|
|
||||||
"Score against job" action is what runs it, deliberately per-row.
|
|
||||||
|
|
||||||
That split is the whole design: ranking the bank costs nothing and happens
|
|
||||||
automatically when a job opens, so scoring can stay explicit and cheap.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
|
|
@ -30,6 +25,7 @@ import OpenResumeButton from '../ui/OpenResumeButton'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||||
|
import { PickRoleModal } from '../ui/SuggestedRoles'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||||
|
|
@ -55,10 +51,11 @@ const SOURCE_BADGE = {
|
||||||
/* Chips past this are collapsed into "+N" — a CV with 25 skills would
|
/* Chips past this are collapsed into "+N" — a CV with 25 skills would
|
||||||
otherwise make one row taller than the rest of the page. */
|
otherwise make one row taller than the rest of the page. */
|
||||||
const SKILL_CHIPS = 4
|
const SKILL_CHIPS = 4
|
||||||
|
const SUGGESTED_CHIPS = 3
|
||||||
|
|
||||||
const EMPTY_FILTERS = { source: '', band: '', years: '' }
|
const EMPTY_FILTERS = { source: '', band: '', years: '' }
|
||||||
|
|
||||||
async function fetchBank({ limit, offset, search, filters, jobPostId }) {
|
async function fetchBank({ limit, offset, search, filters }) {
|
||||||
const res = await candidatesApi.listCvBank({
|
const res = await candidatesApi.listCvBank({
|
||||||
top: limit,
|
top: limit,
|
||||||
skip: offset,
|
skip: offset,
|
||||||
|
|
@ -66,7 +63,6 @@ async function fetchBank({ limit, offset, search, filters, jobPostId }) {
|
||||||
source: filters.source || undefined,
|
source: filters.source || undefined,
|
||||||
band: filters.band || undefined,
|
band: filters.band || undefined,
|
||||||
minYears: filters.years ? Number(filters.years) : undefined,
|
minYears: filters.years ? Number(filters.years) : undefined,
|
||||||
jobPostId: jobPostId || undefined,
|
|
||||||
})
|
})
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
return {
|
return {
|
||||||
|
|
@ -75,30 +71,6 @@ async function fetchBank({ limit, offset, search, filters, jobPostId }) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs() {
|
|
||||||
const res = await candidatesApi.listJobs()
|
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
|
||||||
return rows.map((row) => ({ id: String(row.id), title: row.title }))
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The free deterministic rank. Drawn as a plain bar, never as a ScoreChip —
|
|
||||||
a recruiter must not read it in the same visual language as a real ATS score. */
|
|
||||||
function MatchCell({ rank, hasJob }) {
|
|
||||||
if (!hasJob) return <span className="text-muted text-sm">Pick a job</span>
|
|
||||||
if (rank == null) return <span className="text-muted">—</span>
|
|
||||||
return (
|
|
||||||
<div style={{ minWidth: 92 }}>
|
|
||||||
<div className="fw-600 text-sm">{rank}<span className="text-muted" style={{ fontWeight: 400 }}>/100</span></div>
|
|
||||||
<div
|
|
||||||
style={{ height: 4, borderRadius: 2, background: 'var(--bg-sunken)', marginTop: 4 }}
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div style={{ width: `${Math.min(100, rank)}%`, height: '100%', borderRadius: 2, background: 'var(--primary)' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AtsCell({ score, recommendation }) {
|
function AtsCell({ score, recommendation }) {
|
||||||
if (score == null) return <span className="text-muted text-sm">Not scored</span>
|
if (score == null) return <span className="text-muted text-sm">Not scored</span>
|
||||||
return (
|
return (
|
||||||
|
|
@ -113,11 +85,43 @@ function AtsCell({ score, recommendation }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SuggestedJobsCell({ jobs }) {
|
||||||
|
if (!jobs?.length) return null
|
||||||
|
return (
|
||||||
|
<div className="k-tags">
|
||||||
|
{jobs.slice(0, SUGGESTED_CHIPS).map((j) => (
|
||||||
|
<span className="tag" key={j.id} title={j.title}>{j.title || 'Job'}</span>
|
||||||
|
))}
|
||||||
|
{jobs.length > SUGGESTED_CHIPS && (
|
||||||
|
<span className="tag" title={jobs.slice(SUGGESTED_CHIPS).map((j) => j.title).join(', ')}>
|
||||||
|
+{jobs.length - SUGGESTED_CHIPS}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobFor(row, pickedById) {
|
||||||
|
const local = pickedById[row.id]
|
||||||
|
if (local?.id) return local
|
||||||
|
if (row.scoredJobPostId) {
|
||||||
|
return { id: row.scoredJobPostId, title: row.scoredJobTitle || 'Selected job' }
|
||||||
|
}
|
||||||
|
if (row.assignedJobPostId) {
|
||||||
|
return { id: row.assignedJobPostId, title: row.assignedJobTitle || 'Assigned job' }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoredRow(res) {
|
||||||
|
const row = Array.isArray(res?.data) ? res.data[0] : res?.data
|
||||||
|
return row && typeof row === 'object' ? row : null
|
||||||
|
}
|
||||||
|
|
||||||
export default function CvBank() {
|
export default function CvBank() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [params, setParams] = useSearchParams()
|
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
@ -126,19 +130,8 @@ export default function CvBank() {
|
||||||
const [skip, setSkip] = useState(0)
|
const [skip, setSkip] = useState(0)
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||||
const [preview, setPreview] = useState(null) // { name, url } — object URL we own
|
const [preview, setPreview] = useState(null) // { name, url } — object URL we own
|
||||||
const [scoreFor, setScoreFor] = useState(null)
|
const [pickingRow, setPickingRow] = useState(null)
|
||||||
const [assignFor, setAssignFor] = useState(null)
|
const [pickedById, setPickedById] = useState({})
|
||||||
|
|
||||||
/* The rank job lives in the URL so the notification fired on job creation
|
|
||||||
("/cvbank?job=<id>") lands on the ranked view rather than a generic list. */
|
|
||||||
const jobPostId = params.get('job') || ''
|
|
||||||
const setJobPostId = (next) => {
|
|
||||||
const p = new URLSearchParams(params)
|
|
||||||
if (next) p.set('job', next)
|
|
||||||
else p.delete('job')
|
|
||||||
setParams(p, { replace: true })
|
|
||||||
setSkip(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||||||
|
|
@ -147,15 +140,12 @@ export default function CvBank() {
|
||||||
useEffect(() => { setSkip(0) }, [search])
|
useEffect(() => { setSkip(0) }, [search])
|
||||||
|
|
||||||
const bankQuery = useQuery({
|
const bankQuery = useQuery({
|
||||||
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters, jobPostId }),
|
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters }),
|
||||||
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters, jobPostId }),
|
queryFn: () => fetchBank({ limit: pageSize, offset: skip, search, filters }),
|
||||||
})
|
})
|
||||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
|
||||||
|
|
||||||
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
|
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
|
||||||
const total = bankQuery.data?.total ?? 0
|
const total = bankQuery.data?.total ?? 0
|
||||||
const jobs = jobsQuery.data ?? []
|
|
||||||
const selectedJob = jobs.find((j) => j.id === jobPostId) || null
|
|
||||||
|
|
||||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
const from = total ? skip + 1 : 0
|
const from = total ? skip + 1 : 0
|
||||||
|
|
@ -173,8 +163,9 @@ export default function CvBank() {
|
||||||
{ key: 'title', label: 'Role', sortable: true },
|
{ key: 'title', label: 'Role', sortable: true },
|
||||||
{ key: 'years', label: 'Years', sortable: true },
|
{ key: 'years', label: 'Years', sortable: true },
|
||||||
{ key: 'skills', label: 'Skills', sortable: false },
|
{ key: 'skills', label: 'Skills', sortable: false },
|
||||||
{ key: 'rankScore', label: 'Match', sortable: true },
|
{ key: 'suggestedJobs', label: 'Suggested jobs', sortable: false },
|
||||||
{ key: 'aiScore', label: 'ATS', sortable: true },
|
{ key: 'aiScore', label: 'ATS', sortable: true },
|
||||||
|
{ key: 'job', label: 'Job', sortable: false },
|
||||||
{ key: 'added', label: 'Added', sortable: true },
|
{ key: 'added', label: 'Added', sortable: true },
|
||||||
{ key: 'actions', label: '', sortable: false },
|
{ key: 'actions', label: '', sortable: false },
|
||||||
], [])
|
], [])
|
||||||
|
|
@ -196,33 +187,32 @@ export default function CvBank() {
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const scoring = useMutation({
|
const runAts = useMutation({
|
||||||
mutationFn: ({ jobId, ids }) => candidatesApi.scoreCvBank(jobId, ids),
|
mutationFn: async ({ row, jobId }) => {
|
||||||
onSuccess: (res) => {
|
if (row.isStoredCv) {
|
||||||
const row = Array.isArray(res?.data) ? res.data[0] : null
|
await candidatesApi.assignMatchingJob(row.recordId, jobId)
|
||||||
if (row?.status === 'completed') {
|
return candidatesApi.scoreCvBank(jobId, [row.recordId])
|
||||||
toast(`Scored ${row.match_score}/100 — the result is on Candidates now`, 'success')
|
}
|
||||||
|
if (row.messageId) {
|
||||||
|
return candidatesApi.scoreInbox(jobId, [row.messageId])
|
||||||
|
}
|
||||||
|
throw new Error('This applicant cannot be scored from the CV Bank')
|
||||||
|
},
|
||||||
|
onSuccess: (res, vars) => {
|
||||||
|
const row = scoredRow(res)
|
||||||
|
const title = vars.jobTitle || 'the selected job'
|
||||||
|
if (row?.status === 'completed' || (row?.match_score != null && row?.status !== 'failed')) {
|
||||||
|
toast(`Scored ${row.match_score}/100 against ${title}`, 'success')
|
||||||
} else {
|
} else {
|
||||||
toast(`Could not score the CV${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning')
|
toast(`Could not score the CV${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning')
|
||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||||
setScoreFor(null)
|
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||||
},
|
},
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'),
|
onError: (err) => toast(friendlyAuthError(err, 'Scoring failed'), 'error'),
|
||||||
})
|
})
|
||||||
|
|
||||||
const assigning = useMutation({
|
|
||||||
mutationFn: ({ id, jobId }) => candidatesApi.assignMatchingJob(id, jobId),
|
|
||||||
onSuccess: () => {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
|
||||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
|
||||||
toast('CV assigned — it is in the pipeline now', 'success')
|
|
||||||
setAssignFor(null)
|
|
||||||
},
|
|
||||||
onError: (err) => toast(friendlyAuthError(err, 'Could not assign the job'), 'error'),
|
|
||||||
})
|
|
||||||
|
|
||||||
async function view(row) {
|
async function view(row) {
|
||||||
if (!row.isStoredCv) {
|
if (!row.isStoredCv) {
|
||||||
if (row.userId) navigate(`/candidate/${row.userId}`)
|
if (row.userId) navigate(`/candidate/${row.userId}`)
|
||||||
|
|
@ -269,7 +259,7 @@ export default function CvBank() {
|
||||||
await exportStyledXlsx({
|
await exportStyledXlsx({
|
||||||
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
|
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
|
||||||
title: 'CV Bank',
|
title: 'CV Bank',
|
||||||
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'}${selectedJob ? ` · ranked against ${selectedJob.title}` : ''} · exported ${new Date().toLocaleDateString()}`,
|
subtitle: `${rows.length} CV${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
|
||||||
columns: [
|
columns: [
|
||||||
{ header: 'Name', key: 'name', width: 26 },
|
{ header: 'Name', key: 'name', width: 26 },
|
||||||
{ header: 'Email', key: 'email', width: 30 },
|
{ header: 'Email', key: 'email', width: 30 },
|
||||||
|
|
@ -278,12 +268,11 @@ export default function CvBank() {
|
||||||
{ header: 'Company', key: 'company', width: 24 },
|
{ header: 'Company', key: 'company', width: 24 },
|
||||||
{ header: 'Years', key: 'years', width: 8 },
|
{ header: 'Years', key: 'years', width: 8 },
|
||||||
{ header: 'Skills', key: 'skills', width: 42 },
|
{ header: 'Skills', key: 'skills', width: 42 },
|
||||||
{ header: 'Match', key: 'match', width: 10 },
|
{ header: 'Suggested jobs', key: 'suggested', width: 32 },
|
||||||
{ header: 'ATS', key: 'ats', width: 10 },
|
{ header: 'ATS', key: 'ats', width: 10 },
|
||||||
|
{ header: 'Scored job', key: 'scoredJob', width: 24 },
|
||||||
{ header: 'Added', key: 'added', width: 12 },
|
{ header: 'Added', key: 'added', width: 12 },
|
||||||
],
|
],
|
||||||
// Every column is extracted from the CV or read off a real application.
|
|
||||||
// Talent Pool exported invented skills and companies; this does not.
|
|
||||||
rows: rows.map((r) => ({
|
rows: rows.map((r) => ({
|
||||||
name: r.name,
|
name: r.name,
|
||||||
email: r.email || '',
|
email: r.email || '',
|
||||||
|
|
@ -292,8 +281,9 @@ export default function CvBank() {
|
||||||
company: r.company || '',
|
company: r.company || '',
|
||||||
years: r.years ?? '',
|
years: r.years ?? '',
|
||||||
skills: r.skills.join(', '),
|
skills: r.skills.join(', '),
|
||||||
match: r.rankScore ?? '',
|
suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '),
|
||||||
ats: r.aiScore ?? '',
|
ats: r.aiScore ?? '',
|
||||||
|
scoredJob: jobFor(r, pickedById)?.title || '',
|
||||||
added: r.added ? r.added.toLocaleDateString() : '',
|
added: r.added ? r.added.toLocaleDateString() : '',
|
||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
@ -309,7 +299,7 @@ export default function CvBank() {
|
||||||
title="CV Bank"
|
title="CV Bank"
|
||||||
sub={
|
sub={
|
||||||
bankQuery.isSuccess
|
bankQuery.isSuccess
|
||||||
? <>{total} CV{total === 1 ? '' : 's'} held for future roles{selectedJob ? <> · ranked against <strong>{selectedJob.title}</strong></> : null}</>
|
? <>{total} CV{total === 1 ? '' : 's'} held for future roles</>
|
||||||
: 'CVs held for future roles'
|
: 'CVs held for future roles'
|
||||||
}
|
}
|
||||||
actions={<>
|
actions={<>
|
||||||
|
|
@ -337,20 +327,6 @@ export default function CvBank() {
|
||||||
<Icon name="filter" /> Filters
|
<Icon name="filter" /> Filters
|
||||||
</button>
|
</button>
|
||||||
<div className="spacer" />
|
<div className="spacer" />
|
||||||
{/* The "a role just opened, who do we already have" control. This is
|
|
||||||
the moment the bank is meant to be used. */}
|
|
||||||
<div className="flex items-center gap-8">
|
|
||||||
<label className="text-muted text-sm">Rank against job:</label>
|
|
||||||
<select
|
|
||||||
className="select"
|
|
||||||
value={jobPostId}
|
|
||||||
onChange={(e) => setJobPostId(e.target.value)}
|
|
||||||
disabled={jobsQuery.isPending}
|
|
||||||
>
|
|
||||||
<option value="">No job selected</option>
|
|
||||||
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showFilters && (
|
{showFilters && (
|
||||||
|
|
@ -418,7 +394,16 @@ export default function CvBank() {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
t.pageRows.map((r) => (
|
t.pageRows.map((r) => {
|
||||||
|
const picked = jobFor(r, pickedById)
|
||||||
|
const scoringThis = runAts.isPending && runAts.variables?.row?.id === r.id
|
||||||
|
const runDisabled = !r.canRunAts || !picked || runAts.isPending
|
||||||
|
const runTitle = !r.canRunAts
|
||||||
|
? 'Silver medalists are scored from their application — this row has no inbox CV to score'
|
||||||
|
: !picked
|
||||||
|
? 'Pick a job first'
|
||||||
|
: 'Run the ATS score for this candidate'
|
||||||
|
return (
|
||||||
<tr key={r.id}>
|
<tr key={r.id}>
|
||||||
<td>
|
<td>
|
||||||
<div className="user-cell">
|
<div className="user-cell">
|
||||||
|
|
@ -463,8 +448,36 @@ export default function CvBank() {
|
||||||
<span className="text-muted text-sm">None extracted</span>
|
<span className="text-muted text-sm">None extracted</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td><MatchCell rank={r.rankScore} hasJob={Boolean(jobPostId)} /></td>
|
<td><SuggestedJobsCell jobs={r.suggestedJobs} /></td>
|
||||||
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||||||
|
<td>
|
||||||
|
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
onClick={() => setPickingRow(r)}
|
||||||
|
title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
|
||||||
|
>
|
||||||
|
{picked?.title || 'Pick a job'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
disabled={runDisabled}
|
||||||
|
title={runTitle}
|
||||||
|
onClick={() => {
|
||||||
|
if (!picked || runDisabled) return
|
||||||
|
runAts.mutate({
|
||||||
|
row: r,
|
||||||
|
jobId: picked.id,
|
||||||
|
jobTitle: picked.title,
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{scoringThis ? 'Scoring…' : 'Run ATS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -478,22 +491,7 @@ export default function CvBank() {
|
||||||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||||||
<Icon name="download" />
|
<Icon name="download" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
{!r.assignedJobPostId && (
|
||||||
className="act-btn"
|
|
||||||
data-tip="Score against a job"
|
|
||||||
aria-label="Score this CV against a job"
|
|
||||||
onClick={() => setScoreFor(r)}
|
|
||||||
>
|
|
||||||
<Icon name="target" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="act-btn"
|
|
||||||
data-tip="Assign to a job"
|
|
||||||
aria-label="Assign this CV to a job"
|
|
||||||
onClick={() => setAssignFor(r)}
|
|
||||||
>
|
|
||||||
<Icon name="briefcase" />
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
className="act-btn"
|
className="act-btn"
|
||||||
data-tip="Remove"
|
data-tip="Remove"
|
||||||
|
|
@ -507,11 +505,13 @@ export default function CvBank() {
|
||||||
>
|
>
|
||||||
<Icon name="trash" />
|
<Icon name="trash" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>)}
|
</>)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
)
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
@ -533,36 +533,23 @@ export default function CvBank() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted text-sm mt-18">
|
<p className="text-muted text-sm mt-18">
|
||||||
<Icon name="info" /> <strong>Match</strong> is free keyword overlap against the selected
|
<Icon name="info" /> Pick a job on a row, then <strong>Run ATS</strong> for a
|
||||||
job — it orders this list, it does not assess anyone. <strong>ATS</strong> is a real
|
real scored result. Speculative CVs are linked to that job; silver medalists
|
||||||
scored result and only appears once someone runs one.
|
stay on their existing application.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{scoreFor && (
|
{pickingRow && (
|
||||||
<JobPickerModal
|
<PickRoleModal
|
||||||
title="Score against a job"
|
title="Pick a job"
|
||||||
subtitle={`Run the real ATS score for ${scoreFor.name}`}
|
subtitle="Search open job posts to score this CV against"
|
||||||
note="This calls the scoring model and costs money. The result lands in Candidates like any other scored CV."
|
onClose={() => setPickingRow(null)}
|
||||||
confirmLabel="Score CV"
|
onPick={(post) => {
|
||||||
jobs={jobs}
|
if (!post?.id) return
|
||||||
defaultJobId={jobPostId}
|
setPickedById((m) => ({
|
||||||
pending={scoring.isPending}
|
...m,
|
||||||
onClose={() => setScoreFor(null)}
|
[pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' },
|
||||||
onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })}
|
}))
|
||||||
/>
|
}}
|
||||||
)}
|
|
||||||
|
|
||||||
{assignFor && (
|
|
||||||
<JobPickerModal
|
|
||||||
title="Assign to a job"
|
|
||||||
subtitle={`Move ${assignFor.name} onto a job post`}
|
|
||||||
note="The CV leaves the bank and enters the pipeline as an application. It is not scored by this action."
|
|
||||||
confirmLabel="Assign"
|
|
||||||
jobs={jobs}
|
|
||||||
defaultJobId={jobPostId}
|
|
||||||
pending={assigning.isPending}
|
|
||||||
onClose={() => setAssignFor(null)}
|
|
||||||
onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -598,42 +585,3 @@ function Facet({ label, value, onChange, any, options, labels }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shared by the score and assign actions — both need exactly one job post. */
|
|
||||||
function JobPickerModal({ title, subtitle, note, confirmLabel, jobs, defaultJobId, pending, onClose, onConfirm }) {
|
|
||||||
const [jobId, setJobId] = useState(defaultJobId || (jobs[0]?.id ?? ''))
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title={title}
|
|
||||||
subtitle={subtitle}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={<>
|
|
||||||
<button className="btn btn-secondary" onClick={onClose} disabled={pending}>Cancel</button>
|
|
||||||
<button
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={pending || !jobId}
|
|
||||||
onClick={() => onConfirm(jobId)}
|
|
||||||
>
|
|
||||||
<Icon name="check" /> {pending ? 'Working…' : confirmLabel}
|
|
||||||
</button>
|
|
||||||
</>}
|
|
||||||
>
|
|
||||||
{jobs.length === 0 ? (
|
|
||||||
<EmptyState icon="briefcase" title="No job posts yet">
|
|
||||||
Create a job post first — there is nothing to match against.
|
|
||||||
</EmptyState>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="form-field">
|
|
||||||
<label>Job post</label>
|
|
||||||
<select value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
|
||||||
{jobs.map((j) => <option key={j.id} value={j.id}>{j.title}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p className="text-muted text-sm">{note}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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,7 +2806,6 @@ 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
|
||||||
|
|
@ -2779,21 +2821,26 @@ function ApplicationDetail({
|
||||||
<Icon name="linkedin" /> LinkedIn
|
<Icon name="linkedin" /> LinkedIn
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
{canEdit && (
|
||||||
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
@ -30,6 +30,7 @@ import { ReappliedBadge } from '../components/ReapplicantHistory'
|
||||||
|
|
||||||
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
||||||
export const KANBAN_STAGES = [
|
export const KANBAN_STAGES = [
|
||||||
|
{ name: 'CLOSED', color: 'var(--stage-9)' },
|
||||||
{ name: 'Shortlist', color: 'var(--stage-1)' },
|
{ name: 'Shortlist', color: 'var(--stage-1)' },
|
||||||
{ name: 'Screening', color: 'var(--stage-2)' },
|
{ name: 'Screening', color: 'var(--stage-2)' },
|
||||||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||||
|
|
@ -43,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.
|
||||||
|
|
@ -73,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 {
|
||||||
|
|
@ -113,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({
|
||||||
|
|
@ -221,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) => (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ const RANGES = [
|
||||||
]
|
]
|
||||||
|
|
||||||
/* Order matters: "reached" is a running sum from the end of this list back to
|
/* Order matters: "reached" is a running sum from the end of this list back to
|
||||||
the start. REJECTED, CLOSED (shown as Rejected on the board) and ONHOLD are
|
the start. REJECTED, CLOSED (shown as CLOSED) and ONHOLD are
|
||||||
absent — parking and outcomes are not a step in the happy-path suffix sum. */
|
absent — parking and outcomes are not a step in the happy-path suffix sum. */
|
||||||
const FUNNEL_ORDER = [
|
const FUNNEL_ORDER = [
|
||||||
{ key: 'PENDING', label: 'Shortlist' },
|
{ key: 'PENDING', label: 'Shortlist' },
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue