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
|
||||
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
|
||||
|
||||
_CITY_SENTINELS=frozenset({
|
||||
|
|
@ -101,15 +101,12 @@ def _clean_phone(value,resume_text):
|
|||
text=(value or "").strip()
|
||||
if not text or text.lower() in (NO_PHONE.lower(),"none","null","n/a","-"):
|
||||
return None
|
||||
digits=re.sub(r"\D","",text)
|
||||
if digits.startswith("00"):
|
||||
digits=digits[2:]
|
||||
if len(digits)<10 or len(digits)>15:
|
||||
from employment_agent.plugins import _phone_digits,_phone_score,phone_in_resume
|
||||
digits=_phone_digits(text)
|
||||
if _phone_score(digits)<0:
|
||||
return None
|
||||
if (resume_text or "").strip() and not phone_in_resume(digits,resume_text):
|
||||
return None
|
||||
if (resume_text or "").strip():
|
||||
haystack=re.sub(r"\D","",resume_text)
|
||||
if digits not in haystack:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
|
|
@ -194,6 +191,20 @@ def _clean_skills(value,resume_text):
|
|||
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):
|
||||
"""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_years_experience=clamp_field("years_experience",_clean_years)
|
||||
clamp_city=clamp_field("city",_clean_city)
|
||||
clamp_candidate_name=clamp_field("candidate_name",_clean_name)
|
||||
|
||||
|
||||
@require_json_object
|
||||
|
|
@ -244,18 +256,19 @@ clamp_city=clamp_field("city",_clean_city)
|
|||
@clamp_skills
|
||||
@clamp_years_experience
|
||||
@clamp_city
|
||||
@clamp_candidate_name
|
||||
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.
|
||||
|
||||
skills and years_experience default to []/None when the key is absent, so a
|
||||
model reply predating the extended prompt still parses — the inbox match
|
||||
path reads the other five keys and must not break on a partial response.
|
||||
skills, years_experience, and candidate_name default to []/None/"" when the
|
||||
key is absent, so a model reply predating the extended prompt still parses.
|
||||
"""
|
||||
def as_str(key):
|
||||
value=data.get(key)
|
||||
return value.strip() if isinstance(value,str) else ""
|
||||
return {
|
||||
"candidate_name":as_str("candidate_name"),
|
||||
"current_employment":as_str("current_employment"),
|
||||
"education":as_str("education"),
|
||||
"current_title":as_str("current_title"),
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ async def run_employment_agent(*,resume_text=""):
|
|||
text=(resume_text or "").strip()
|
||||
if not text:
|
||||
return {
|
||||
"candidate_name":"",
|
||||
"current_employment":NO_COMPANY,
|
||||
"education":EDUCATION,
|
||||
"current_title":CURRENT_TITLE,
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ Call like the rest of the backend:
|
|||
fields=parse_linkedin({"linkedin_url":raw},resume_text)
|
||||
url=fields["linkedin_url"]
|
||||
|
||||
`scan_phone` is the regex guts `prefer_extracted_phone` uses so the stacked
|
||||
parser cannot recurse into itself.
|
||||
`scan_phone` is the digit-span scan `prefer_extracted_phone` uses so the
|
||||
stacked parser cannot recurse into itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,16 +23,43 @@ from employment_agent.decorators import (
|
|||
prefer_extracted_phone,
|
||||
)
|
||||
|
||||
_PK_MOBILE=re.compile(
|
||||
r"(?:(?:\+|00)[\s\-.]*)?(?:92[\s\-.]*)?0?3\d{2}(?:[\s\-.\n]*\d){7}"
|
||||
)
|
||||
_PHONE_SPAN=re.compile(
|
||||
r"(?:(?:\+|00)[\s\-.]*)?(?:\(?\d[\s\-()./\n]*){8,16}\d"
|
||||
)
|
||||
# PDF extraction uses en/em dashes, nbsp, and bullets as digit separators.
|
||||
_DASH_TO_HYPHEN=str.maketrans({
|
||||
"\u2010":"-","\u2011":"-","\u2012":"-","\u2013":"-","\u2014":"-",
|
||||
"\u2015":"-","\u2212":"-","\u2043":"-","\uFE58":"-","\uFE63":"-",
|
||||
"\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:
|
||||
digits=re.sub(r"\D","",raw or "")
|
||||
digits=_digits_only(raw)
|
||||
if digits.startswith("00"):
|
||||
digits=digits[2:]
|
||||
return digits
|
||||
|
|
@ -54,37 +81,90 @@ def _phone_score(digits:str) -> int:
|
|||
return n
|
||||
|
||||
|
||||
def _phone_keys(digits:str) -> set[str]:
|
||||
"""03XX / +92 3XX / 3XX national forms of the same PK mobile."""
|
||||
d=_phone_digits(digits) if re.search(r"\D",digits or "") else (digits or "")
|
||||
if d.startswith("00"):
|
||||
d=d[2:]
|
||||
keys={d}
|
||||
if d.startswith("92") and len(d)>=12:
|
||||
rest=d[2:]
|
||||
keys.add(rest)
|
||||
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:
|
||||
return value,score
|
||||
return best,best_score
|
||||
|
||||
|
||||
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:
|
||||
"""Regex scan of CV text — complete numbers only, never a truncated prefix."""
|
||||
best=None
|
||||
best_score=-1
|
||||
haystack=text or ""
|
||||
for pattern in (_PK_MOBILE,_PHONE_SPAN):
|
||||
"""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):
|
||||
raw=re.sub(r"[\n\r]+"," ",match.group(0))
|
||||
raw=re.sub(r"[\s\-()]+"," ",raw).strip()
|
||||
score=_phone_score(_phone_digits(raw))
|
||||
if score>best_score:
|
||||
best_score=score
|
||||
best=raw
|
||||
if best_score>=180:
|
||||
return best
|
||||
best,best_score=_consider(match.group(1),best,best_score)
|
||||
return best
|
||||
|
||||
|
||||
def prefer_full_phone(*candidates) -> str|None:
|
||||
"""Keep the candidate with the most digits (min 10). Truncated regex loses."""
|
||||
best=None
|
||||
best_n=-1
|
||||
"""Keep the strongest complete number. Truncated / CNIC-shaped values lose."""
|
||||
best,best_score=None,-1
|
||||
for raw in candidates:
|
||||
value=(raw or "").strip()
|
||||
if not value:
|
||||
continue
|
||||
n=len(_phone_digits(value))
|
||||
if n>=10 and n>best_n:
|
||||
best_n=n
|
||||
best=value
|
||||
return best
|
||||
best,best_score=_consider(value,best,best_score)
|
||||
return best if best_score>=0 else None
|
||||
|
||||
|
||||
def _as_str(data,key):
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ CURRENT_TITLE="No JOB POSITION MENTIONED"
|
|||
NO_LINKEDIN="no linkedin url mentioned"
|
||||
NO_PHONE="no phone number 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.
|
||||
- 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():
|
||||
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
|
||||
LinkedIn profile URL, their phone number, their city of residence, their skills,
|
||||
and their total years of professional experience, when present.
|
||||
|
||||
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 education 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 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):
|
||||
- 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.
|
||||
|
|
@ -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.
|
||||
- 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.
|
||||
- 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}
|
||||
|
||||
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"
|
||||
JSON:
|
||||
{{
|
||||
"candidate_name": "Ali Khan",
|
||||
"current_employment": "Acme",
|
||||
"education": "BS CS",
|
||||
"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:
|
||||
{{
|
||||
"candidate_name": "Full Name",
|
||||
"current_employment": "Company Name",
|
||||
"education": "Degree / School",
|
||||
"current_title": "Job Title",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ from fastapi import APIRouter,Depends,HTTPException,Query
|
|||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
import uuid
|
||||
|
||||
from db_setup import get_session
|
||||
from g_sheet.views import (
|
||||
|
|
@ -25,6 +26,21 @@ def _city_values(city: str | 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):
|
||||
rows: list[list[str]]
|
||||
|
||||
|
|
@ -209,6 +225,8 @@ async def fetch_form_data(
|
|||
source: str | None = Query(None),
|
||||
assigned: 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),
|
||||
limit: int | None = Query(None,ge=1,le=500),
|
||||
current_user: dict = Depends(_FORM_DATA_READ),
|
||||
|
|
@ -222,6 +240,7 @@ async def fetch_form_data(
|
|||
has_linkedin=has_linkedin,has_resume=has_resume,
|
||||
city=_city_values(city),source=(source or "").strip() or None,
|
||||
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})
|
||||
except HTTPException:
|
||||
|
|
@ -243,6 +262,7 @@ async def fetch_form_data_counts(
|
|||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
assigned: bool | None = Query(None),
|
||||
job_post_ids: str | None = Query(None),
|
||||
current_user: dict = Depends(_FORM_DATA_READ),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
|
|
@ -251,6 +271,7 @@ async def fetch_form_data_counts(
|
|||
data=await service.get_counts(
|
||||
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
|
||||
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})
|
||||
except HTTPException:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||
import uuid
|
||||
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.ext.asyncio import AsyncSession
|
||||
from sqlmodel import Field, SQLModel, select
|
||||
|
|
@ -127,6 +127,108 @@ class FormData(SQLModel, table=True):
|
|||
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
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
|
|
@ -143,7 +245,7 @@ class FormData(SQLModel, table=True):
|
|||
def _filters(
|
||||
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=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 = []
|
||||
if sheet:
|
||||
|
|
@ -151,7 +253,10 @@ class FormData(SQLModel, table=True):
|
|||
if processing_state:
|
||||
filters.append(cls.processing_state == processing_state)
|
||||
if is_duplicate is not None:
|
||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
||||
if is_duplicate:
|
||||
filters.append(cls._duplicates_tab_filter())
|
||||
else:
|
||||
filters.append(cls.is_duplicate == bool(is_duplicate))
|
||||
if has_linkedin is not None:
|
||||
matches = [cls.profile_link.ilike(p) for p in LINKEDIN_PATTERNS]
|
||||
if has_linkedin:
|
||||
|
|
@ -188,6 +293,10 @@ class FormData(SQLModel, table=True):
|
|||
filters.append(cls.job_post_id.is_(None))
|
||||
if no_suggestions is True:
|
||||
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":
|
||||
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
|
||||
elif inbox_filter == "unassigned":
|
||||
|
|
@ -383,7 +492,7 @@ class FormData(SQLModel, table=True):
|
|||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
processing_state=None, is_duplicate=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,
|
||||
offset=0, limit=None,
|
||||
):
|
||||
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,
|
||||
city=city, source=source, assigned=assigned,
|
||||
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
if offset:
|
||||
|
|
@ -553,7 +663,7 @@ class FormData(SQLModel, table=True):
|
|||
cls, session: AsyncSession, *, sheet=None, search=None,
|
||||
processing_state=None, is_duplicate=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,
|
||||
):
|
||||
statement = select(func.count()).select_from(cls)
|
||||
for clause in cls._filters(
|
||||
|
|
@ -562,6 +672,7 @@ class FormData(SQLModel, table=True):
|
|||
has_linkedin=has_linkedin, has_resume=has_resume,
|
||||
city=city, source=source, assigned=assigned,
|
||||
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
):
|
||||
statement = statement.where(clause)
|
||||
result = await session.execute(statement)
|
||||
|
|
@ -571,6 +682,7 @@ class FormData(SQLModel, table=True):
|
|||
async def count_processing(
|
||||
cls, session: AsyncSession, *, sheet=None, search=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.
|
||||
|
||||
|
|
@ -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 == "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.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("suggested"),
|
||||
).select_from(cls)
|
||||
for clause in cls._filters(
|
||||
sheet=sheet, search=search,
|
||||
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)
|
||||
row = (await session.execute(statement)).one()
|
||||
|
|
@ -608,6 +721,7 @@ class FormData(SQLModel, table=True):
|
|||
"rejected": int(row.rejected or 0),
|
||||
"duplicates": int(row.duplicates or 0),
|
||||
"on_hold": int(row.on_hold or 0),
|
||||
"suggested": int(row.suggested or 0),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -437,6 +437,7 @@ class SheetFormData(Sheet):
|
|||
self,sheet=None,search=None,offset=0,limit=None,
|
||||
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
|
||||
city=None,source=None,assigned=None,no_suggestions=None,
|
||||
has_suggestions=None,job_post_ids=None,
|
||||
):
|
||||
session=self._require_session()
|
||||
rows=await FormData.fetch_form_data(
|
||||
|
|
@ -444,12 +445,14 @@ class SheetFormData(Sheet):
|
|||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
source=source,assigned=assigned,no_suggestions=no_suggestions,
|
||||
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||
)
|
||||
total=await FormData.count_form_data(
|
||||
session,sheet=sheet,search=search,
|
||||
processing_state=processing_state,is_duplicate=is_duplicate,
|
||||
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
|
||||
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])
|
||||
from job.candidate.views import CandidateView
|
||||
|
|
@ -600,11 +603,11 @@ class SheetFormData(Sheet):
|
|||
raise HTTPException(status_code=404,detail="Form data not found")
|
||||
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(
|
||||
self._require_session(),sheet=sheet,search=search,
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import hmac
|
||||
import os
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter,Depends, Query
|
||||
|
|
@ -26,6 +27,21 @@ def _city_values(city: str | 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):
|
||||
body={"data":items,"total":total,"status_code":200}
|
||||
if cities is not None:
|
||||
|
|
@ -66,6 +82,10 @@ class AssignJobPostBody(BaseModel):
|
|||
job_post_id: str | None = None
|
||||
|
||||
|
||||
class AssignRecruiterBody(BaseModel):
|
||||
recruiter_id: str | None = None
|
||||
|
||||
|
||||
class ProcessingStateBody(BaseModel):
|
||||
processing_state: str
|
||||
|
||||
|
|
@ -103,9 +123,11 @@ class ReadAllBody(BaseModel):
|
|||
assigned: bool | None = None
|
||||
is_duplicate: bool | None = None
|
||||
no_suggestions: bool | None = None
|
||||
has_suggestions: bool | None = None
|
||||
processing_state: str | None = None
|
||||
city: str | None = None
|
||||
source: str | None = None
|
||||
job_post_ids: str | None = None
|
||||
|
||||
|
||||
class TriageOverrideBody(BaseModel):
|
||||
|
|
@ -257,6 +279,23 @@ async def assign_job_post(
|
|||
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")
|
||||
async def mark_inbox_read(
|
||||
record_id: str,
|
||||
|
|
@ -314,6 +353,8 @@ async def mark_all_inbox_read(
|
|||
processing_state=payload.processing_state,
|
||||
city=_city_values(payload.city),
|
||||
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})
|
||||
except HTTPException:
|
||||
|
|
@ -346,10 +387,12 @@ async def get_all_applications(
|
|||
assigned: bool | None = Query(default=None),
|
||||
is_duplicate: 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),
|
||||
search: str | None = Query(None),
|
||||
city: str | None = Query(None),
|
||||
source: str | None = Query(None),
|
||||
job_post_ids: str | None = Query(None),
|
||||
city_list: bool = Query(default=False),
|
||||
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
|
||||
top: int | None = Query(None, ge=1, le=500),
|
||||
|
|
@ -361,23 +404,25 @@ async def get_all_applications(
|
|||
service=Email(session=session)
|
||||
city_values=_city_values(city)
|
||||
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
|
||||
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:
|
||||
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)
|
||||
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)
|
||||
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, **extra)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
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)
|
||||
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)
|
||||
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, **extra)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
if record_id:
|
||||
item=await service.get_application_by_id(record_id)
|
||||
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)
|
||||
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)
|
||||
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,**extra)
|
||||
return _apps_payload(items,total,cities,sources)
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
from shlex import join
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -9,7 +10,7 @@ from dotenv import load_dotenv
|
|||
from fastapi import HTTPException
|
||||
from inbox.enums import Candidate_application_Status
|
||||
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.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
|
@ -36,6 +37,20 @@ def _now() -> datetime:
|
|||
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):
|
||||
__tablename__ = "inbox"
|
||||
|
||||
|
|
@ -74,7 +89,7 @@ class Inbox(SQLModel, table=True):
|
|||
)
|
||||
|
||||
@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:
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
|
|
@ -124,6 +139,9 @@ class Inbox(SQLModel, table=True):
|
|||
qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids))
|
||||
elif 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:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
result=await session.execute(qry)
|
||||
|
|
@ -261,11 +279,17 @@ class Inbox(SQLModel, table=True):
|
|||
Inbox_Messages.current_employment.label("current_company"),
|
||||
Inbox_Messages.current_title,
|
||||
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_path,
|
||||
Inbox_Messages.ats_score,
|
||||
Inbox_Messages.ats_band,
|
||||
Inbox_Messages.assigned_job_post_id,
|
||||
Inbox_Messages.suggested_job_post_ids,
|
||||
cls.created_at,
|
||||
JobPosts.id.label("last_job_post_id"),
|
||||
JobPosts.title.label("last_job_title"),
|
||||
Candidates.matched_keywords,
|
||||
Candidates.years_experience,
|
||||
|
|
@ -298,13 +322,18 @@ class Inbox(SQLModel, table=True):
|
|||
"current_company":row["current_company"] or None,
|
||||
"current_title":row["current_title"] or None,
|
||||
"education":row["education"] or None,
|
||||
"city":row["city"] or None,
|
||||
"file_name":row["file_name"] or None,
|
||||
"file_path":row["file_path"] or None,
|
||||
"ai_score":int(row["ats_score"]) if row["ats_score"] is not None else 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,
|
||||
"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,
|
||||
"created_at":row["created_at"],
|
||||
})
|
||||
|
|
@ -332,7 +361,7 @@ class Inbox(SQLModel, table=True):
|
|||
return out
|
||||
|
||||
@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:
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
|
|
@ -349,6 +378,9 @@ class Inbox(SQLModel, table=True):
|
|||
)
|
||||
if 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)
|
||||
counts={}
|
||||
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))
|
||||
|
||||
@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:
|
||||
if job_post_ids is not None and not list(job_post_ids):
|
||||
return []
|
||||
|
|
@ -388,11 +450,8 @@ class Inbox(SQLModel, table=True):
|
|||
qry = qry.where(cls.user_id == user_id)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
if job_post_ids is not None:
|
||||
qry = (
|
||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
)
|
||||
if not user_id:
|
||||
qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
|
||||
# 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.
|
||||
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))
|
||||
|
||||
@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."""
|
||||
try:
|
||||
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)
|
||||
if search:
|
||||
qry = qry.where(cls._candidate_search_filter(search))
|
||||
if job_post_ids is not None:
|
||||
qry = (
|
||||
qry.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.where(Inbox_Messages.assigned_job_post_id.in_(list(job_post_ids)))
|
||||
)
|
||||
if not user_id:
|
||||
qry = cls._with_job_link(qry, job_post_ids, assignment=assignment)
|
||||
result = await session.execute(qry)
|
||||
return result.scalar_one()
|
||||
except Exception as e:
|
||||
|
|
@ -475,7 +531,7 @@ class Inbox(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
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
|
||||
|
||||
|
|
@ -1026,6 +1082,56 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
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
|
||||
def _cities_match(column, cities):
|
||||
"""Agent city name vs stored raw text: Karachi matches Karachi(Malir)."""
|
||||
|
|
@ -1049,6 +1155,8 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
city=None,
|
||||
source: 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.
|
||||
|
||||
|
|
@ -1068,11 +1176,15 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
if isread==False:
|
||||
statement = statement.where(cls.message_read==False)
|
||||
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:
|
||||
statement = statement.where(cls.is_duplicate==False) # noqa: E712
|
||||
if no_suggestions is True:
|
||||
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:
|
||||
statement = statement.where(cls.processing_state == processing_state)
|
||||
cities = [c.strip() for c in (city or []) if (c or "").strip()]
|
||||
|
|
@ -1112,12 +1224,13 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def get_inbox_messages(
|
||||
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, 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(
|
||||
select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state, city, source,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
)
|
||||
if skip:
|
||||
statement = statement.offset(skip)
|
||||
|
|
@ -1210,6 +1323,24 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
await session.refresh(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
|
||||
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.
|
||||
|
|
@ -1228,11 +1359,12 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
return {str(job_id): int(n) for job_id, n in result.all()}
|
||||
|
||||
@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(
|
||||
select(func.count()).select_from(cls),
|
||||
search, isread, application_status, assigned, is_duplicate,
|
||||
no_suggestions, processing_state, city, source,
|
||||
has_suggestions=has_suggestions, job_post_ids=job_post_ids,
|
||||
)
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one()
|
||||
|
|
@ -1351,6 +1483,8 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
no_suggestions: bool | None=None,
|
||||
city=None,
|
||||
source: str | None=None,
|
||||
has_suggestions: bool | None=None,
|
||||
job_post_ids=None,
|
||||
) -> int:
|
||||
"""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(
|
||||
update(cls),search,isread,application_status,assigned,is_duplicate,
|
||||
no_suggestions,processing_state,city,source,
|
||||
has_suggestions=has_suggestions,job_post_ids=job_post_ids,
|
||||
)
|
||||
statement=statement.where(cls.message_read!=bool(read))
|
||||
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 == "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.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("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_(None), 1), else_=0)), 0).label("unassigned"),
|
||||
).where(cls.attachment == True) # noqa: E712
|
||||
|
|
@ -1392,6 +1528,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
"rejected": int(row.rejected or 0),
|
||||
"duplicates": int(row.duplicates or 0),
|
||||
"on_hold": int(row.on_hold or 0),
|
||||
"suggested": int(row.suggested or 0),
|
||||
"assigned": int(row.assigned or 0),
|
||||
"unassigned": int(row.unassigned or 0),
|
||||
}
|
||||
|
|
@ -1470,7 +1607,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
or_(cls.recruiter_id == rid, JobPosts.has_recruiter(rid))
|
||||
)
|
||||
return statement
|
||||
|
||||
|
|
@ -1537,7 +1674,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
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)
|
||||
result = await session.execute(statement)
|
||||
|
|
@ -1581,7 +1718,7 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
rid = None
|
||||
if rid is not None:
|
||||
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)
|
||||
result = await session.execute(statement)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,24 @@ from pathlib import Path
|
|||
|
||||
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.
|
||||
_RESUME_STATUS = {
|
||||
"processing": "Parsing",
|
||||
|
|
@ -85,6 +103,14 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
|
|||
"ats_score": message.ats_score,
|
||||
"ats_band": message.ats_band 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_band": message.ats_band or None,
|
||||
"professional_summary": message.professional_summary or None,
|
||||
"phone": message.candidate_phone_number,
|
||||
"experience": message.experience or "",
|
||||
"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,
|
||||
"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,
|
||||
"processing_state": message.processing_state,
|
||||
"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)
|
||||
status=result.get("status") or "failed"
|
||||
if status=="failed":
|
||||
raise RuntimeError(result.get("error") or "agent returned failed status")
|
||||
|
||||
# Extract the profile even when matching finds no job — On-Hold / unassigned
|
||||
# CVs still need name, title, years, and phone on every screen.
|
||||
fields=await run_employment_agent(
|
||||
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"]
|
||||
phone=fields["phone"]
|
||||
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:
|
||||
await Inbox_Messages.set_match_result(
|
||||
session,
|
||||
record_id,
|
||||
resume_text=text,
|
||||
experience=result.get("experience") or "",
|
||||
candidate_phone_number=phone,
|
||||
experience=experience,
|
||||
candidate_phone_number=phone if phone else "",
|
||||
current_employment=current_employment,
|
||||
current_title=current_title,
|
||||
candidate_education=education,
|
||||
|
|
|
|||
|
|
@ -259,13 +259,14 @@ class Email:
|
|||
items=await self._attach_job_posts([item])
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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])
|
||||
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
|
||||
items=await self._attach_job_posts(items)
|
||||
|
|
@ -310,6 +311,19 @@ class Email:
|
|||
suggested.append(dict(payload))
|
||||
item["suggested_job_posts"]=suggested
|
||||
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
|
||||
|
||||
async def _paint_inbox_ats(self,items):
|
||||
|
|
@ -767,13 +781,14 @@ class Email:
|
|||
results.append({"email":email,"sent":False})
|
||||
return results
|
||||
|
||||
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,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:
|
||||
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:
|
||||
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:
|
||||
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):
|
||||
"""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)
|
||||
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):
|
||||
message=await Inbox_Messages.mark_message_read(self.session,record_id,read)
|
||||
if not message:
|
||||
|
|
@ -849,7 +881,7 @@ class Email:
|
|||
async def set_read_all(self,read,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):
|
||||
city=None,source=None,has_suggestions=None,job_post_ids=None):
|
||||
"""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
|
||||
|
|
@ -861,6 +893,7 @@ class Email:
|
|||
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
|
||||
no_suggestions=no_suggestions,processing_state=processing_state,
|
||||
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",
|
||||
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
|
||||
description: str | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
hiring_manager_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(
|
||||
session,
|
||||
candidate_email=detected or "",
|
||||
candidate_name="",
|
||||
candidate_name=(profile.get("candidate_name") or "").strip(),
|
||||
full_text=text,
|
||||
file_name=original,
|
||||
created_by=current_user.get("id"),
|
||||
|
|
@ -1024,7 +1025,8 @@ async def fetch_manager_candidates(
|
|||
async def fetch_candidate(
|
||||
user_id:str=Query(None),
|
||||
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),
|
||||
search:str=Query(None),
|
||||
created_by:Optional[bool]=Query(False),
|
||||
|
|
@ -1032,14 +1034,17 @@ async def fetch_candidate(
|
|||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
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)
|
||||
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(
|
||||
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
|
||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||
except HTTPException:
|
||||
|
|
@ -1336,6 +1341,7 @@ async def change_candidate_stage(
|
|||
@router.get("/pipeline/candidates/fetch")
|
||||
async def fetch_pipeline_candidates(
|
||||
job_post_id:Optional[uuid.UUID]=Query(None),
|
||||
search:Optional[str]=Query(None),
|
||||
limit:int=Query(10,ge=1,le=1000),
|
||||
offset:int=Query(0,ge=0),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)),
|
||||
|
|
@ -1343,7 +1349,7 @@ async def fetch_pipeline_candidates(
|
|||
):
|
||||
try:
|
||||
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})
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -82,6 +82,50 @@ class JobAssignments(SQLModel, table=True):
|
|||
await session.commit()
|
||||
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
|
||||
async def insert_assignment(cls, session: AsyncSession, fields: dict):
|
||||
row = cls(**fields)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,16 @@ class Assignment:
|
|||
"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):
|
||||
if not job_post_id:
|
||||
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
|
||||
row=await self.record_job_owner(job_post_id,user_id,role,assigned_by)
|
||||
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:
|
||||
try:
|
||||
from notifications.views import notify_job_assignment
|
||||
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(
|
||||
self.session,updated,
|
||||
role_label=label,
|
||||
actor_id=assigned_by,
|
||||
previous_ids=[
|
||||
job.hiring_manager_id if role=="hiring_manager" else job.current_recruiter_id
|
||||
],
|
||||
previous_ids=previous,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("notification insert skipped: %s", exc)
|
||||
|
|
|
|||
|
|
@ -197,19 +197,25 @@ async def _notify_owner(job_post_id: str, count: int) -> None:
|
|||
job = await JobPosts.get_job_post_by_id(session, job_post_id)
|
||||
if job is None:
|
||||
return
|
||||
raw = getattr(job, "current_recruiter_id", None) or getattr(job, "created_by", None)
|
||||
if not raw:
|
||||
ids = JobPosts.recruiter_ids_of(job)
|
||||
if not ids:
|
||||
created = getattr(job, "created_by", None)
|
||||
if created:
|
||||
ids = [str(created)]
|
||||
if not ids:
|
||||
return
|
||||
await Notifications.insert_notification(session, {
|
||||
"user_id": _uuid.UUID(str(raw)),
|
||||
"kind": "application",
|
||||
"title": "CVs in the bank match this job",
|
||||
"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}",
|
||||
"job_post_id": job.id,
|
||||
})
|
||||
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, {
|
||||
"user_id": _uuid.UUID(str(raw)),
|
||||
"kind": "application",
|
||||
"title": "CVs in the bank match this job",
|
||||
"body": body,
|
||||
"link_path": f"/cvbank?job={job_post_id}",
|
||||
"job_post_id": job.id,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("cv-bank suggestion notification failed job=%s", job_post_id)
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
|
||||
|
||||
@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:
|
||||
from inbox.models import AtsResults
|
||||
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))
|
||||
elif 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:
|
||||
qry=qry.limit(limit).offset(offset)
|
||||
result=await session.execute(qry)
|
||||
|
|
@ -174,7 +182,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
@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:
|
||||
from users.models import Users
|
||||
from job.job_post.models import JobPosts
|
||||
|
|
@ -187,6 +195,14 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
)
|
||||
if 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)
|
||||
counts={}
|
||||
for status,n in result.all():
|
||||
|
|
@ -225,7 +241,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
except (TypeError,ValueError):
|
||||
rid=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)
|
||||
result=await session.execute(qry)
|
||||
counts={}
|
||||
|
|
@ -258,7 +274,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
qry=qry.where(JobPosts.department==department)
|
||||
rid=cls._as_uuid(recruiter_id)
|
||||
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)
|
||||
result=await session.execute(qry)
|
||||
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
|
||||
|
||||
@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 + job for Talent Pool (manual / form)."""
|
||||
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())
|
||||
)
|
||||
def _talent_pool_filters(cls, Users, *, search=None, job_post_ids=None):
|
||||
filters = [cls.user_id.is_not(None), cls.job_post_id.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:
|
||||
like = f"%{search.strip()}%"
|
||||
statement = statement.where(
|
||||
filters.append(
|
||||
or_(
|
||||
cls.candidate_name.ilike(like),
|
||||
cls.candidate_email.ilike(like),
|
||||
|
|
@ -509,10 +515,41 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
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)
|
||||
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
|
||||
async def sources_by_user_ids(cls, session: AsyncSession, user_ids) -> dict[str, str]:
|
||||
"""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(),
|
||||
skills=extracted.get("skills") or [],
|
||||
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_expires_at=expires_at,
|
||||
apply_via="cv_bank",
|
||||
|
|
@ -673,8 +711,13 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def list_bank(cls, session: AsyncSession, limit=100, offset=0):
|
||||
"""Unassigned No-job CVs only — assigned rows leave the bank for Matching."""
|
||||
bank = (cls.apply_via == "cv_bank", cls.job_post_id.is_(None))
|
||||
"""Speculative CVs (`apply_via=cv_bank`), including ones later linked to a job.
|
||||
|
||||
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 = (
|
||||
await session.execute(
|
||||
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))
|
||||
if row is 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():
|
||||
row.current_company = (profile.get("current_company") 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 []
|
||||
if row.years_experience is None:
|
||||
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"):
|
||||
row.linkedin_url = profile["linkedin_url"]
|
||||
row.linkedin_slug = slug_from_url(profile["linkedin_url"]) or NO_SLUG
|
||||
|
|
@ -1101,6 +1151,38 @@ class Candidates(SQLModel, table=True):
|
|||
})
|
||||
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
|
||||
async def upsert_candidate(cls, session: AsyncSession, fields: dict):
|
||||
existing = None
|
||||
|
|
@ -1252,7 +1334,7 @@ class Interviews(SQLModel, table=True):
|
|||
.outerjoin(Inbox, cls.inbox_id == Inbox.id)
|
||||
.outerjoin(Inbox_Messages, Inbox.message_id == Inbox_Messages.id)
|
||||
.outerjoin(JobPosts, JobPosts.id == job_id)
|
||||
.where(JobPosts.current_recruiter_id == rid)
|
||||
.where(JobPosts.has_recruiter(rid))
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -1726,7 +1808,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
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)
|
||||
value = result.scalar_one()
|
||||
|
|
@ -1750,7 +1832,7 @@ class ApplicationStageTransitions(SQLModel, table=True):
|
|||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@ from job.activity.serializers import serialize_activity
|
|||
from job.feedback.serializers import serialize_feedback
|
||||
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:
|
||||
return {
|
||||
"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
|
||||
otherwise collide in a merged list.
|
||||
|
||||
rank_score is the deterministic tier-1 overlap against whichever job the
|
||||
recruiter is ranking by; it is None until they pick one, and it is NOT an
|
||||
ATS score — ai_score is.
|
||||
rank_score is optional keyword overlap used by /cv-bank/suggestions, not
|
||||
by the CV Bank table. ai_score is filled after serialize by joining the
|
||||
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"
|
||||
assigned=_id_str(row.job_post_id)
|
||||
return {
|
||||
"id":f"bank:{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_position":(row.current_position 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 []),
|
||||
"years_experience":row.years_experience,
|
||||
"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_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,
|
||||
"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,
|
||||
"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"
|
||||
expires=get("bank_expires_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 {
|
||||
"id":f"app:{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_position":get("current_title") or None,
|
||||
"education":get("education") or None,
|
||||
"city":get("city") or None,
|
||||
# Inbox applications never ran the skills extraction — their structured
|
||||
# signal is the ATS score, which is stronger than a keyword list.
|
||||
"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"),
|
||||
"recommendation":get("recommendation"),
|
||||
"rank_score":rank_score,
|
||||
"last_job_title":get("last_job_title") or None,
|
||||
"last_job_title":last_title,
|
||||
"bank_reason":"silver_medalist",
|
||||
"bank_expires_at":expires.isoformat() if hasattr(expires,"isoformat") else expires,
|
||||
"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,
|
||||
"updated_at":None,
|
||||
}
|
||||
|
|
@ -272,7 +303,7 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]:
|
|||
"message_id": None,
|
||||
"created_at": created,
|
||||
"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_title": position,
|
||||
"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,
|
||||
"rating": None,
|
||||
"phone": (row.candidate_phone or "").strip() or None,
|
||||
"education": None,
|
||||
"education": (row.education or "").strip() or None,
|
||||
"currentCompany": company,
|
||||
"stage": row.status 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:
|
||||
"""One application on a hiring-manager's job — list row, not the profile."""
|
||||
inbox_id = row.get("inbox_id")
|
||||
|
|
@ -347,9 +451,8 @@ _WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
|
|||
def is_assigned_application(row) -> bool:
|
||||
"""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
|
||||
job post is still history; it is not a reapplication. Sheet forms name a
|
||||
role in job_title even before a job post is linked.
|
||||
Sheet forms name a role in job_title even before a job post is linked.
|
||||
Unassigned inbox mail is still a kept attempt — see is_kept_application.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
|
|
@ -360,6 +463,24 @@ def is_assigned_application(row) -> bool:
|
|||
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:
|
||||
"""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:
|
||||
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)
|
||||
return {
|
||||
"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}
|
||||
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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from job.candidate.plugins import (
|
|||
normalize_spaced_text,
|
||||
)
|
||||
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.serializers import serialize_job_post
|
||||
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
|
||||
/ assigned hiring_manager_id. Recruiter assignment on the job does not hide
|
||||
those candidates. candidates.manage or admin → None (all applications).
|
||||
Otherwise → current_recruiter_id when set, else created_by. Never role_id.
|
||||
created_by=True skips current_recruiter_id and matches job_posts.created_by
|
||||
Otherwise → current_recruiter_ids / current_recruiter_id when set, else created_by. Never role_id.
|
||||
created_by=True skips recruiter assignment and matches job_posts.created_by
|
||||
to the session user (ignored when the user is requisition-scoped).
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
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):
|
||||
"""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)
|
||||
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:
|
||||
return [requested] if requested else None
|
||||
return requested
|
||||
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)
|
||||
|
||||
|
||||
|
|
@ -274,12 +296,14 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
|
|||
"""
|
||||
blank={
|
||||
"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()
|
||||
if not text:
|
||||
return blank
|
||||
try:
|
||||
from employment_agent.decorators import _clean_years
|
||||
from employment_agent.execute_agent import run_employment_agent
|
||||
from employment_agent.plugins import parse_linkedin
|
||||
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")
|
||||
except Exception:
|
||||
url=None
|
||||
years=fields.get("years_experience")
|
||||
years=_clean_years(fields.get("years_experience"),text)
|
||||
return {
|
||||
"linkedin_url":url,
|
||||
"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(),
|
||||
"city":(fields.get("city") or "").strip() or None,
|
||||
"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:
|
||||
|
|
@ -1204,7 +1229,7 @@ class CandidateView:
|
|||
page=merged[start:start+cap]
|
||||
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:
|
||||
if not user_id and is_hiring_manager(current_user):
|
||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||
|
|
@ -1212,136 +1237,161 @@ class CandidateView:
|
|||
await assert_manager_candidate_access(
|
||||
self.session,current_user,user_id=user_id,created_by=created_by,
|
||||
)
|
||||
detail=bool(user_id)
|
||||
list_job_ids=None
|
||||
if not detail:
|
||||
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,
|
||||
return await self._get_candidate_detail(
|
||||
user_id,limit=limit,offset=offset,search=search,
|
||||
current_user=current_user,created_by=created_by,
|
||||
)
|
||||
if list_job_ids is not None and not list_job_ids:
|
||||
return []
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||
job_post_ids=list_job_ids,
|
||||
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,
|
||||
)
|
||||
if detail:
|
||||
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)
|
||||
if records and owned is not None:
|
||||
owned_set=set(owned)
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
# Manual uploads create users + manual_upload_candidate but no inbox
|
||||
# row — resolve the profile from that table instead of returning [].
|
||||
manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id)
|
||||
if not manual:
|
||||
return []
|
||||
if owned is not None:
|
||||
owned_set=set(owned)
|
||||
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if 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)
|
||||
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)
|
||||
if score:
|
||||
payload["ai_score"]=score["overall_score"]
|
||||
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||
payload["scored_at"]=score["computed_at"]
|
||||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
# List mode: inbox applications + manual/form applications (dedupe by user).
|
||||
inbox_payloads=await self.attach_job_posts(rows)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
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,
|
||||
if list_job_ids is not None and not list_job_ids:
|
||||
return []
|
||||
return await self._list_candidates(
|
||||
limit=limit,offset=offset,search=search,job_post_ids=list_job_ids,assignment=assignment,
|
||||
)
|
||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||
manual_payloads=[]
|
||||
for manual in manual_rows:
|
||||
uid=str(manual.user_id) if manual.user_id else None
|
||||
if uid and uid in seen:
|
||||
continue
|
||||
user=await Users.get_user_by_id(self.session,manual.user_id) if manual.user_id else None
|
||||
job_post=None
|
||||
if 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)
|
||||
# List shape matches attach_job_posts: keep job_posts, drop heavy detail.
|
||||
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:
|
||||
seen.add(uid)
|
||||
from inbox.plugins import get_ats_scores_for_users
|
||||
owners=[p.get("user_id") for p in manual_payloads if p.get("user_id")]
|
||||
ats=await get_ats_scores_for_users(self.session,owners)
|
||||
for payload in manual_payloads:
|
||||
row=ats.get(str(payload.get("user_id") or ""))
|
||||
if row and row.get("overall_score") is not None:
|
||||
payload["ai_score"]=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 _get_candidate_detail(self,user_id,limit=10,offset=0,search=None,current_user=None,created_by=False):
|
||||
rows=await Inbox.get_candidate_profile(
|
||||
session=self.session,user_id=user_id,limit=limit,offset=offset,search=search,
|
||||
)
|
||||
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)
|
||||
if records and owned is not None:
|
||||
owned_set=set(owned)
|
||||
kept=[]
|
||||
for rec in records:
|
||||
msg=getattr(rec,"messages",None)
|
||||
jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None
|
||||
if jid and jid in owned_set:
|
||||
kept.append(rec)
|
||||
if kept:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(kept))
|
||||
records=[]
|
||||
if records:
|
||||
return await self.attach_application_history(await self.attach_profile_detail(rows))
|
||||
return await self._get_manual_detail(user_id,owned,current_user)
|
||||
|
||||
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)
|
||||
if not manual:
|
||||
return []
|
||||
if owned is not None:
|
||||
owned_set=set(owned)
|
||||
if not manual.job_post_id or manual.job_post_id not in owned_set:
|
||||
raise HTTPException(status_code=403,detail=_scope_detail(current_user))
|
||||
user=await Users.get_user_by_id(self.session,user_id)
|
||||
job_post=None
|
||||
if 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)
|
||||
score=await get_ats_score_for_manual_user(self.session,user_id,manual.job_post_id)
|
||||
if score:
|
||||
payload["ai_score"]=score["overall_score"]
|
||||
payload["recommendation"]=self._recommendation(score["overall_score"])
|
||||
payload["scored_at"]=score["computed_at"]
|
||||
payload["candidate_id"]=score.get("candidate_id")
|
||||
if score.get("user_id") and not payload.get("user_id"):
|
||||
payload["user_id"]=score["user_id"]
|
||||
if score and score.get("job_post_id"):
|
||||
payload["scored_job_post_id"]=score["job_post_id"]
|
||||
return await self.attach_application_history(payload)
|
||||
|
||||
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)
|
||||
if not isinstance(inbox_payloads,list):
|
||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||
seen_emails={(p.get("email") or "").strip().lower() for p in inbox_payloads if (p.get("email") or "").strip()}
|
||||
manual_payloads=await self._list_manual_payloads(
|
||||
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:
|
||||
continue
|
||||
user=await Users.get_user_by_id(self.session,row.user_id) if row.user_id else None
|
||||
job_post=None
|
||||
if row.job_post_id:
|
||||
job_post=await JobPosts.get_job_post_by_id(self.session,str(row.job_post_id))
|
||||
payload=serialize_manual_candidate_list(serialize_manual_candidate_profile(row,user,job_post))
|
||||
payloads.append(payload)
|
||||
if 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
|
||||
owners=[p.get("user_id") for p in payloads if p.get("user_id")]
|
||||
ats=await get_ats_scores_for_users(self.session,owners)
|
||||
for payload in payloads:
|
||||
row=ats.get(str(payload.get("user_id") or ""))
|
||||
if row and row.get("overall_score") is not None:
|
||||
payload["ai_score"]=row["overall_score"]
|
||||
payload["recommendation"]=row.get("band") or self._recommendation(row["overall_score"])
|
||||
|
||||
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False,assignment=None):
|
||||
try:
|
||||
job_post_ids=None
|
||||
if not user_id:
|
||||
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,
|
||||
)
|
||||
return await Inbox.count_candidate_profiles(
|
||||
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
|
||||
if job_post_ids is not None and not 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:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
@ -1410,6 +1460,47 @@ class CandidateView:
|
|||
data["job_title"]=payload.get("title")
|
||||
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):
|
||||
"""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):
|
||||
"""The unified CV Bank: speculative uploads plus scored rejections.
|
||||
|
||||
job_post_id does not filter — it attaches the tier-1 rank_score for
|
||||
that job and sorts by it, which is how a recruiter "pulls from" the
|
||||
bank when an opening appears.
|
||||
job_post_id does not filter — it is only used by /cv-bank/suggestions
|
||||
to attach rank_score. The CV Bank screen itself no longer ranks.
|
||||
"""
|
||||
from inbox.models import Inbox
|
||||
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=await self._attach_bank_ats(rows)
|
||||
rows=await self._hydrate_bank_job_titles(rows)
|
||||
if 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"))
|
||||
|
||||
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):
|
||||
"""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.
|
||||
|
||||
``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)
|
||||
records=[payloads] if single else list(payloads or [])
|
||||
|
|
@ -1870,7 +2025,7 @@ class CandidateView:
|
|||
for row in pack.get("applications") or []:
|
||||
item=serialize_application_history_item(row)
|
||||
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
|
||||
items.sort(key=lambda r: r.get("applied_at") or "",reverse=True)
|
||||
payload["present_in"]=list(pack.get("present_in") or [])
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ class HiringCosts(SQLModel, table=True):
|
|||
statement = statement.where(JobPosts.department == department)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
||||
statement = statement.where(JobPosts.has_recruiter(rid))
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import uuid
|
|||
from datetime import datetime, timezone
|
||||
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.orm import aliased, load_only
|
||||
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"})
|
||||
closed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True))
|
||||
# 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_ids: list[str] = Field(
|
||||
default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"},
|
||||
)
|
||||
# Who owns the requisition (stable). Optional. History lives in
|
||||
# job_assignments with assignment_role=hiring_manager.
|
||||
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:
|
||||
try:
|
||||
return uuid.UUID(str(record_id))
|
||||
except ValueError:
|
||||
except (TypeError, ValueError):
|
||||
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
|
||||
async def get_job_post_by_id(cls, session: AsyncSession, record_id: str):
|
||||
uid = cls._as_uuid(record_id)
|
||||
|
|
@ -457,6 +512,7 @@ class JobPosts(SQLModel, table=True):
|
|||
cls.location,
|
||||
cls.requisition_status,
|
||||
cls.current_recruiter_id,
|
||||
cls.current_recruiter_ids,
|
||||
cls.created_at,
|
||||
Recruiter.name.label("recruiter_name"),
|
||||
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):
|
||||
"""Jobs this recruiter should see on Candidates (when they lack
|
||||
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)
|
||||
if uid is None:
|
||||
return []
|
||||
|
|
@ -568,8 +624,8 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(
|
||||
select(cls.id).where(
|
||||
or_(
|
||||
and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid),
|
||||
and_(cls.current_recruiter_id.is_(None), cls.created_by == uid),
|
||||
cls.has_recruiter(uid),
|
||||
and_(cls.no_recruiters(), cls.created_by == uid),
|
||||
),
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
|
@ -599,12 +655,12 @@ class JobPosts(SQLModel, table=True):
|
|||
cls, session: AsyncSession, recruiter_id, *, status, department=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)
|
||||
if uid is None:
|
||||
return 0
|
||||
statement = select(func.count()).select_from(cls).where(
|
||||
cls.current_recruiter_id == uid,
|
||||
cls.has_recruiter(uid),
|
||||
cls.requisition_status == status,
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
)
|
||||
|
|
@ -623,7 +679,7 @@ class JobPosts(SQLModel, table=True):
|
|||
statement = statement.where(cls.department == department)
|
||||
uid = cls._as_uuid(recruiter_id) if recruiter_id is not None else None
|
||||
if uid is not None:
|
||||
statement = statement.where(cls.current_recruiter_id == uid)
|
||||
statement = statement.where(cls.has_recruiter(uid))
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from job.job_post.enums import RequisitionStatus
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
|
||||
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 {
|
||||
"id": str(row.id),
|
||||
"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,
|
||||
"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,
|
||||
**_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.
|
||||
|
||||
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.
|
||||
"""
|
||||
req = getattr(row, "requisition", None)
|
||||
payload = _recruiter_payload(row, names)
|
||||
if recruiter_name and not payload["recruiter_name"]:
|
||||
payload["recruiter_name"] = recruiter_name
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"title": row.title,
|
||||
|
|
@ -79,8 +99,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
|||
"description": row.description,
|
||||
"is_active": row.is_active,
|
||||
"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,
|
||||
"recruiter_name": recruiter_name,
|
||||
**payload,
|
||||
"hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None,
|
||||
"hiring_manager_name": hiring_manager_name,
|
||||
"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."""
|
||||
recruiter_id=row.get("current_recruiter_id")
|
||||
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 {
|
||||
"job_post_id": str(row["job_post_id"]),
|
||||
"title": row["title"],
|
||||
"department": row["department"] or None,
|
||||
"location": row["location"],
|
||||
"requisition_status": row["requisition_status"],
|
||||
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
|
||||
"recruiter_name": row.get("recruiter_name") or None,
|
||||
**payload,
|
||||
# Frontend computes days-open vs client clock; no server days_open field.
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
return uuid.UUID(str(job_post_id))
|
||||
|
|
@ -67,6 +93,7 @@ class JobPostCreate(BaseModel):
|
|||
due_at: str | None = None
|
||||
hiring_manager_id: UUID | None = None
|
||||
current_recruiter_id: UUID | None = None
|
||||
current_recruiter_ids: list[UUID] | None = None
|
||||
requisition_id: UUID | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
|
@ -84,6 +111,31 @@ class JobPost:
|
|||
self.buffer_api=os.getenv("BUFFER_API")
|
||||
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):
|
||||
"""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",
|
||||
)
|
||||
fields["hiring_manager_id"]=hm.id
|
||||
rec=None
|
||||
if payload.get("current_recruiter_id"):
|
||||
rec=await assignment.require_role(
|
||||
payload.get("current_recruiter_id"),EnumRoles.RECRUITER,"current_recruiter_id",
|
||||
)
|
||||
fields["current_recruiter_id"]=rec.id
|
||||
rec_users=[]
|
||||
raw_ids=_payload_recruiter_ids(payload)
|
||||
if raw_ids:
|
||||
rec_users=await self._resolve_recruiters(assignment,raw_ids)
|
||||
fields.update(_recruiter_fields(rec_users))
|
||||
|
||||
if payload.get("requisition_id"):
|
||||
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
|
||||
if hm:
|
||||
await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by)
|
||||
if rec:
|
||||
await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by)
|
||||
if rec_users:
|
||||
await assignment.record_job_recruiters(row.id,[u.id for u in rec_users],assigned_by)
|
||||
|
||||
try:
|
||||
from notifications.views import notify_job_created
|
||||
|
|
@ -204,7 +255,7 @@ class JobPost:
|
|||
await self._rank_cv_bank(row.id)
|
||||
|
||||
if not publish:
|
||||
return serialize_job_post(row)
|
||||
return await self._serialize_post(row)
|
||||
|
||||
try:
|
||||
post=await create_buffer_post(
|
||||
|
|
@ -227,7 +278,7 @@ class JobPost:
|
|||
sent_at=parse_buffer_datetime(post.get("sentAt")),
|
||||
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):
|
||||
"""Queue the tier-1 rank of every banked CV against a brand-new job.
|
||||
|
|
@ -281,7 +332,11 @@ class JobPost:
|
|||
active_only=active_only,
|
||||
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):
|
||||
uid=None
|
||||
|
|
@ -298,7 +353,11 @@ class JobPost:
|
|||
skip=skip,
|
||||
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 not data:
|
||||
raise HTTPException(status_code=404,detail="Job post not found")
|
||||
|
|
@ -341,13 +400,13 @@ class JobPost:
|
|||
)
|
||||
names=await Users.names_by_ids(
|
||||
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])
|
||||
return [
|
||||
serialize_job_row(
|
||||
r,
|
||||
recruiter_name=names.get(str(r.current_recruiter_id)),
|
||||
names=names,
|
||||
hiring_manager_name=names.get(str(r.hiring_manager_id)),
|
||||
applicant_count=counts.get(str(r.id),0),
|
||||
)
|
||||
|
|
@ -357,11 +416,11 @@ class JobPost:
|
|||
async def _job_row(self,row):
|
||||
names=await Users.names_by_ids(
|
||||
self.session,
|
||||
[row.current_recruiter_id,row.hiring_manager_id],
|
||||
JobPosts.recruiter_ids_of(row)+[row.hiring_manager_id],
|
||||
)
|
||||
return serialize_job_row(
|
||||
row,
|
||||
recruiter_name=names.get(str(row.current_recruiter_id)),
|
||||
names=names,
|
||||
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",
|
||||
)
|
||||
fields["requisition_id"]=req.id
|
||||
if "current_recruiter_id" in payload:
|
||||
raw=payload.get("current_recruiter_id")
|
||||
if raw is None or raw=="":
|
||||
fields["current_recruiter_id"]=None
|
||||
rec_changed=existing.current_recruiter_id is not None
|
||||
else:
|
||||
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)
|
||||
rec_users=None
|
||||
raw_ids=_payload_recruiter_ids(payload)
|
||||
if raw_ids is not None:
|
||||
rec_users=await self._resolve_recruiters(assignment,raw_ids)
|
||||
fields.update(_recruiter_fields(rec_users))
|
||||
rec_changed=JobPosts.recruiter_ids_of(existing)!=[str(u.id) for u in rec_users]
|
||||
|
||||
if not fields:
|
||||
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,
|
||||
)
|
||||
if rec_changed:
|
||||
await assignment.record_job_owner(
|
||||
job_post_id,fields.get("current_recruiter_id"),"primary_recruiter",assigned_by,
|
||||
await assignment.record_job_recruiters(
|
||||
job_post_id,fields.get("current_recruiter_ids") or [],assigned_by,
|
||||
)
|
||||
if hm_changed or rec_changed:
|
||||
try:
|
||||
|
|
@ -458,7 +514,7 @@ class JobPost:
|
|||
self.session,row,
|
||||
role_label=" and ".join(labels),
|
||||
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:
|
||||
logger.warning("notification insert skipped: %s",exc)
|
||||
|
|
|
|||
|
|
@ -13,21 +13,21 @@ class Pipeline:
|
|||
def __init__(self,session:AsyncSession):
|
||||
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
|
||||
# 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
|
||||
# the caller can drive paging off them.
|
||||
try:
|
||||
inbox_data=await Inbox.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)
|
||||
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,search=search)
|
||||
from job.candidate.views import CandidateView
|
||||
history=CandidateView(session=self.session)
|
||||
inbox_data=await history.attach_application_history(inbox_data)
|
||||
manual_upload_data=await history.attach_application_history(manual_upload_data)
|
||||
counts=serialize_pipeline_counts(
|
||||
await Inbox.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),
|
||||
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,search=search),
|
||||
)
|
||||
return {
|
||||
"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):
|
||||
"""Recruiters currently linked to the job post.
|
||||
|
||||
Uses the live pointer (current_recruiter_id) and open job_assignments
|
||||
rows with assignment_role=primary_recruiter.
|
||||
Uses the live pointer (current_recruiter_id), the JSON list
|
||||
(current_recruiter_ids), and open job_assignments rows with
|
||||
assignment_role=primary_recruiter.
|
||||
"""
|
||||
ids = set()
|
||||
if job is None:
|
||||
|
|
@ -218,6 +219,10 @@ async def job_recruiter_ids(session, job):
|
|||
uid = _as_uuid(getattr(job, "current_recruiter_id", None))
|
||||
if uid is not None:
|
||||
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
|
||||
rows = await JobAssignments.fetch_by_job(
|
||||
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)
|
||||
rid = cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
statement = statement.where(JobPosts.current_recruiter_id == rid)
|
||||
statement = statement.where(JobPosts.has_recruiter(rid))
|
||||
return statement
|
||||
|
||||
@classmethod
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ and how a system-dropped attempt is labelled.
|
|||
"""
|
||||
from job.candidate.serializers import (
|
||||
is_assigned_application,
|
||||
is_kept_application,
|
||||
rejection_reason,
|
||||
serialize_application_history,
|
||||
serialize_application_history_item,
|
||||
|
|
@ -22,6 +23,7 @@ def test_unassigned_inbox_is_not_a_reapplication():
|
|||
"match_status": "matched",
|
||||
}
|
||||
assert is_assigned_application(row) is False
|
||||
assert is_kept_application(row) is True
|
||||
assert rejection_reason(row) is None
|
||||
|
||||
|
||||
|
|
@ -48,6 +50,30 @@ def test_unreadable_cv_is_wrong_format():
|
|||
item = serialize_application_history_item(row)
|
||||
assert item["status"] == "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():
|
||||
|
|
@ -60,20 +86,27 @@ def test_filtered_classifier_row_is_wrong_format():
|
|||
assert rejection_reason(row) == "wrong_format"
|
||||
history = serialize_application_history("a@x.com", applications=[row])
|
||||
assert history["is_reapplicant"] is False
|
||||
assert history["applications"][0]["status"] == "WRONG_FORMAT"
|
||||
assert history["applications"] == []
|
||||
|
||||
|
||||
def test_history_reapplicant_needs_an_assigned_job():
|
||||
unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True}
|
||||
assigned = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer", "status": "PENDING"}
|
||||
def test_history_reapplicant_counts_two_unassigned_mails():
|
||||
unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True, "match_status": "matched"}
|
||||
only_mail = serialize_application_history("a@x.com", applications=[unassigned])
|
||||
assert only_mail["is_reapplicant"] is False
|
||||
assert len(only_mail["applications"]) == 1
|
||||
both = serialize_application_history("a@x.com", applications=[unassigned, assigned])
|
||||
both = serialize_application_history("a@x.com", applications=[unassigned, dict(unassigned)])
|
||||
assert both["is_reapplicant"] is True
|
||||
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():
|
||||
"""List payloads use `source` for the To address, not 'inbox'."""
|
||||
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():
|
||||
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',
|
||||
})
|
||||
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',
|
||||
}).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({
|
||||
inbox_id: 44,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/**
|
||||
* CV Bank mapper — the two populations, and the two numbers that must not be
|
||||
* confused (free rank_score vs paid ai_score).
|
||||
* CV Bank mapper — speculative vs silver medalist, ATS score, suggested jobs,
|
||||
* and the per-row job the last ATS ran against.
|
||||
*
|
||||
* node cvbank.test.mjs
|
||||
*/
|
||||
|
|
@ -56,7 +56,6 @@ const speculative = toBankRowView({
|
|||
years_experience: 6,
|
||||
ai_score: null,
|
||||
recommendation: null,
|
||||
rank_score: null,
|
||||
bank_reason: 'speculative',
|
||||
bank_expires_at: '2028-09-03T00: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('extracted skills come through', speculative.skills.join(',') === 'Python,FastAPI,Docker')
|
||||
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('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('added parses to a Date', speculative.added instanceof Date)
|
||||
|
||||
|
|
@ -89,6 +92,12 @@ const silver = toBankRowView({
|
|||
ai_score: 88,
|
||||
recommendation: 'Strong Match',
|
||||
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',
|
||||
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('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('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',
|
||||
record_id: '2',
|
||||
bank_source: 'speculative',
|
||||
name: 'Ranked',
|
||||
name: 'Scored',
|
||||
skills: [],
|
||||
rank_score: 72,
|
||||
ai_score: null,
|
||||
ai_score: 84,
|
||||
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',
|
||||
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 ------------------------------------------- */
|
||||
|
||||
|
|
@ -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('no expiry stays null', sparse.expiresAt === null)
|
||||
ok('unknown source defaults to speculative', sparse.source === 'speculative')
|
||||
ok('empty suggested-jobs cell stays empty', sparse.suggestedJobs.length === 0)
|
||||
|
||||
const zeroYears = toBankRowView({
|
||||
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({
|
||||
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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
* that drop outcomes can slice it off without re-sorting. */
|
||||
const BOARD_STAGE_ORDER = [
|
||||
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'CLOSED', 'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||
]
|
||||
|
||||
/**
|
||||
* Fold /analytics/funnel/fetch rows (11 enum statuses) onto the pipeline
|
||||
* columns. Same mapping the board uses, so CLOSED is Rejected and ONHOLD /
|
||||
* APPROVED keep their own columns rather than folding into Screening / Hired.
|
||||
* columns. Same mapping the board uses, so CLOSED stays CLOSED and only
|
||||
* REJECTED is Rejected. ONHOLD / APPROVED keep their own columns.
|
||||
*/
|
||||
export function toBoardStageRows(funnelRows, { includeRejected = false } = {}) {
|
||||
const folded = toStageCounts(
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { toDate } from '../lib/format'
|
|||
|
||||
Recruiter pickers use /tasks/assignees/fetch; hiring-manager pickers use
|
||||
/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.
|
||||
============================================================ */
|
||||
|
||||
|
|
|
|||
|
|
@ -59,9 +59,8 @@ export function uploadToCvBank(file) {
|
|||
* The CV Bank — GET /candidate/cv-bank/fetch. Two populations in one list:
|
||||
* speculative uploads with no job, and rejected applicants who scored well.
|
||||
*
|
||||
* `jobPostId` does NOT filter. It attaches rank_score (deterministic keyword
|
||||
* overlap against that job) and sorts by it — the "a role just opened, who do
|
||||
* we already have" view.
|
||||
* `jobPostId` is unused by the CV Bank screen. The suggestions endpoint still
|
||||
* passes it to attach rank_score for notifications on job create.
|
||||
*/
|
||||
export function listCvBank({
|
||||
top = 100, skip = 0, source, search, skills, minYears, band, jobPostId,
|
||||
|
|
@ -238,6 +237,12 @@ function statusKey(value) {
|
|||
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) {
|
||||
if (recommendation) return recommendation
|
||||
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 aiScore = rawScore == null || rawScore === '' ? null : Number(rawScore)
|
||||
const score = Number.isFinite(aiScore) ? aiScore : null
|
||||
const id = row.inbox_id != null
|
||||
? `inbox:${row.inbox_id}`
|
||||
: (row.manual_upload_candidate_id
|
||||
? `manual:${row.manual_upload_candidate_id}`
|
||||
: (row.form_data_id
|
||||
? `form:${row.form_data_id}`
|
||||
: String(row.user_id || row.id || name)))
|
||||
return {
|
||||
id: row.inbox_id != null
|
||||
? `inbox:${row.inbox_id}`
|
||||
: (row.manual_upload_candidate_id
|
||||
? `manual:${row.manual_upload_candidate_id}`
|
||||
: String(row.user_id || row.id || name)),
|
||||
id,
|
||||
userId: row.user_id || null,
|
||||
name,
|
||||
email: row.email ?? null,
|
||||
|
|
@ -274,11 +282,15 @@ export function toApplicationListView(row) {
|
|||
jobTitle,
|
||||
recruiter: row.recruiter || null,
|
||||
applicationStatus: status || null,
|
||||
stage: status ? (STAGE_FROM_STATUS[status] ?? 'Shortlist') : null,
|
||||
stage: listStageOf(row.application_status ?? row.stage),
|
||||
source: row.source || null,
|
||||
aiScore: score,
|
||||
recommendation: bandOf(score, row.recommendation || 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),
|
||||
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
|
||||
}
|
||||
|
|
@ -289,30 +301,50 @@ export const BANK_SOURCE_LABELS = {
|
|||
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.
|
||||
*
|
||||
* Two numbers that must never be confused: `aiScore` is a real paid ATS score
|
||||
* and only exists once someone ran one; `rankScore` is free keyword overlap
|
||||
* against whichever job is selected. The screen renders them differently on
|
||||
* purpose.
|
||||
* `aiScore` is a real paid ATS score and only exists once someone ran one.
|
||||
* Suggested jobs come from inbox silver-medalist payloads; speculative rows
|
||||
* typically have none. `scoredJobPostId` is the job the last ATS ran against.
|
||||
*/
|
||||
export function toBankRowView(row) {
|
||||
const score = row.ai_score == null || row.ai_score === '' ? null : Number(row.ai_score)
|
||||
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 === ''
|
||||
? null
|
||||
: Number(row.years_experience)
|
||||
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 {
|
||||
id: String(row.id || ''),
|
||||
recordId: row.record_id != null ? String(row.record_id) : null,
|
||||
source: row.bank_source || 'speculative',
|
||||
sourceLabel: BANK_SOURCE_LABELS[row.bank_source] || 'Speculative',
|
||||
// A silver medalist is read live from their application, so removing or
|
||||
// re-scoring them is not the bank's call to make.
|
||||
isStoredCv: row.bank_source !== 'silver_medalist',
|
||||
// re-scoring them via the bank score endpoint is not the bank's call.
|
||||
isStoredCv,
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email ?? null,
|
||||
phone: row.phone ?? null,
|
||||
|
|
@ -322,11 +354,18 @@ export function toBankRowView(row) {
|
|||
company: row.current_company ?? null,
|
||||
title: row.current_position ?? null,
|
||||
education: row.education ?? null,
|
||||
city: row.city ?? null,
|
||||
skills: Array.isArray(row.skills) ? row.skills : [],
|
||||
years: Number.isFinite(years) ? years : null,
|
||||
aiScore,
|
||||
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,
|
||||
bankReason: row.bank_reason ?? 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
|
||||
* 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', {
|
||||
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_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', {
|
||||
// `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
|
||||
|
|
@ -29,9 +29,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
|
||||
// Same for `is_duplicate`: omit unless the Duplicates tab.
|
||||
// `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
|
||||
// processed, not application_status PROCESS).
|
||||
// `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.
|
||||
params: {
|
||||
search,
|
||||
|
|
@ -43,9 +45,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
|
|||
assigned,
|
||||
is_duplicate: isDuplicate,
|
||||
no_suggestions: noSuggestions,
|
||||
has_suggestions: hasSuggestions,
|
||||
processing_state: processingState,
|
||||
city,
|
||||
source,
|
||||
job_post_ids: jobPostIds,
|
||||
city_list: cityList,
|
||||
},
|
||||
})
|
||||
|
|
@ -142,7 +146,7 @@ export function bulkSetRead(recordIds, read) {
|
|||
* Resolves to `{updated, read}`, where `updated` counts rows that actually
|
||||
* 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', {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
|
|
@ -153,9 +157,11 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
|
|||
assigned,
|
||||
is_duplicate: isDuplicate,
|
||||
no_suggestions: noSuggestions,
|
||||
has_suggestions: hasSuggestions,
|
||||
processing_state: processingState,
|
||||
city,
|
||||
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. */
|
||||
export function rematch(recordId) {
|
||||
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 ?? '—',
|
||||
requisitionStatus: row.requisition_status,
|
||||
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,
|
||||
daysOpen: daysOpen(createdAt),
|
||||
total: Number(row.total_applicants) || 0,
|
||||
|
|
|
|||
|
|
@ -64,8 +64,28 @@ function experienceLabel(min, max) {
|
|||
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. */
|
||||
export function toJobView(row) {
|
||||
const recruiterIds = recruiterIdsFrom(row)
|
||||
const recruiterNames = recruiterNamesFrom(row)
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
|
|
@ -76,8 +96,10 @@ export function toJobView(row) {
|
|||
platform: row.platform || null,
|
||||
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status,
|
||||
publishStatus: row.status,
|
||||
recruiter: row.recruiter_name,
|
||||
recruiterId: row.current_recruiter_id,
|
||||
recruiter: recruiterNames.join(', ') || null,
|
||||
recruiterId: recruiterIds[0] || null,
|
||||
recruiterIds,
|
||||
recruiterNames,
|
||||
hiringManager: row.hiring_manager_name,
|
||||
hiringManagerId: row.hiring_manager_id,
|
||||
createdByName: row.created_by_name,
|
||||
|
|
|
|||
|
|
@ -17,9 +17,8 @@ import { toDate } from '../lib/format'
|
|||
* Candidate_application_Status (backend/inbox/enums.py) -> the board column.
|
||||
*
|
||||
* 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
|
||||
* an application that did not progress — it reads as Rejected, same as the
|
||||
* board's Rejected column, not as Shortlist.
|
||||
* used to be folded into Hired / Screening). CLOSED is the inbox default and
|
||||
* is shown as CLOSED. Only REJECTED reads as Rejected.
|
||||
*
|
||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||
* board — a card with no column is a candidate nobody sees.
|
||||
|
|
@ -34,7 +33,7 @@ export const STAGE_FROM_STATUS = {
|
|||
OFFER: 'Offer',
|
||||
APPROVED: 'Approved',
|
||||
HIRED: 'Hired',
|
||||
CLOSED: 'Rejected',
|
||||
CLOSED: 'CLOSED',
|
||||
REJECTED: 'Rejected',
|
||||
}
|
||||
|
||||
|
|
@ -46,6 +45,7 @@ export const STAGE_FROM_STATUS = {
|
|||
* (not CLOSED) so new drops are distinguishable from the inbox default.
|
||||
*/
|
||||
export const STATUS_FROM_STAGE = {
|
||||
CLOSED: 'CLOSED',
|
||||
Shortlist: 'PENDING',
|
||||
Screening: 'SCREENING',
|
||||
'On Hold': 'ONHOLD',
|
||||
|
|
@ -81,9 +81,9 @@ export function changeStage({ inboxId, manualUploadId, toStage, changeReason })
|
|||
* (pipeline.view). Envelope is `{ data: { inbox, manual_upload }, counts, total }`.
|
||||
* `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', {
|
||||
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({
|
||||
sheet, search, offset = 0, limit, processing_state, is_duplicate,
|
||||
hasLinkedin, hasResume, city, source, assigned, no_suggestions,
|
||||
has_suggestions, job_post_ids,
|
||||
} = {}) {
|
||||
return request('/sheet/form-data/fetch', {
|
||||
params: {
|
||||
sheet, search, offset, limit, processing_state, is_duplicate,
|
||||
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
|
||||
* 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', {
|
||||
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 = {
|
||||
CLOSED: 'b-gray',
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
|
|
@ -65,7 +66,7 @@ export function candidateApplicationsOf(row) {
|
|||
if (self) items.push(self)
|
||||
}
|
||||
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. */
|
||||
|
|
@ -98,6 +99,9 @@ function syntheticCurrentApplication(row) {
|
|||
job_title: jobTitle,
|
||||
status: row.applicationStatus || row.processingState || row.status || 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)))
|
||||
}
|
||||
|
||||
function hasAssignedJob(item) {
|
||||
if (!item) return false
|
||||
if (item.job_post_id || item.jobPostId) return true
|
||||
return item.source === 'form' && Boolean(item.job_title || item.jobTitle)
|
||||
function isWrongFormat(item) {
|
||||
if (!item) return true
|
||||
if (item.rejection_reason === 'wrong_format' || item.rejectionReason === 'wrong_format') return true
|
||||
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) {
|
||||
if (!row) return false
|
||||
return previousApplicationsOf(row).some(hasAssignedJob)
|
||||
return previousApplicationsOf(row).some(isKeptAttempt)
|
||||
}
|
||||
|
||||
export function previousApplicationsTip(row) {
|
||||
|
|
@ -177,7 +191,7 @@ export function previousApplicationsTip(row) {
|
|||
/** Compact chip for tables, kanban cards, and inbox rows. */
|
||||
export function ReappliedBadge({ row, className = '' }) {
|
||||
if (!isReapplicant(row)) return null
|
||||
const count = previousApplicationsOf(row).filter(hasAssignedJob).length
|
||||
const count = previousApplicationsOf(row).filter(isKeptAttempt).length
|
||||
return (
|
||||
<span
|
||||
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.source === 'form' && item.form_data_id) {
|
||||
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`
|
||||
}
|
||||
if (item.source === 'manual') {
|
||||
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||
if (item.manual_upload_candidate_id) {
|
||||
return `/matching?record=${encodeURIComponent(item.manual_upload_candidate_id)}`
|
||||
}
|
||||
const userId = item.user_id || item.userId || row?.userId || row?.user_id
|
||||
return userId ? `/candidate/${encodeURIComponent(userId)}` : null
|
||||
}
|
||||
if (item.user_id) return `/candidate/${encodeURIComponent(item.user_id)}`
|
||||
if (item.form_data_id) {
|
||||
|
|
@ -246,7 +258,7 @@ export function PreviousApplications({ row, title = 'Total applications' }) {
|
|||
{items.map((item, idx) => {
|
||||
const stage = applicationStatusLabel(item.status, item)
|
||||
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 key = [
|
||||
item.source,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { companies, moneyK, pick } from '../data/seed'
|
|||
Forms) → track (Notes, Activity) → audit (Timeline, History). */
|
||||
const TABS = ['Overview', 'Resume', 'Interview', 'Forms', 'Notes', 'Activity', 'Timeline', 'History']
|
||||
// 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 }
|
||||
|
||||
function tabFromSearch(tabParam, visibleTabs, fallback) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
/* ============================================================
|
||||
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.
|
||||
Without candidates.manage the server scopes that list to jobs the user
|
||||
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 { 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 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_BADGE = {
|
||||
'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
|
||||
columns have no source at all. One row per application is what a recruiter
|
||||
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({
|
||||
limit,
|
||||
offset,
|
||||
search: search || undefined,
|
||||
assignedJobPostId: assignedJobPostId || undefined,
|
||||
assignment: assignment || undefined,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return {
|
||||
|
|
@ -136,6 +137,7 @@ const REFERRAL_RE = new RegExp(
|
|||
const referralValue = (raw) => (raw || '').trim().toLowerCase()
|
||||
|
||||
const STAGE_BADGE = {
|
||||
CLOSED: 'b-gray',
|
||||
Shortlist: 'b-indigo',
|
||||
Screening: 'b-teal',
|
||||
Assessment: 'b-purple',
|
||||
|
|
@ -323,18 +325,26 @@ function RecruiterCandidates() {
|
|||
}, [q])
|
||||
useEffect(() => { setSkip(0) }, [search])
|
||||
|
||||
const assignmentParam = filters.assignment === 'Assigned'
|
||||
? 'assigned'
|
||||
: filters.assignment === 'Unassigned'
|
||||
? 'unassigned'
|
||||
: undefined
|
||||
|
||||
const candidatesQuery = useQuery({
|
||||
queryKey: qk.candidates.list({
|
||||
limit: pageSize,
|
||||
offset: skip,
|
||||
search,
|
||||
assignedJobPostId: jobId || undefined,
|
||||
assignment: assignmentParam,
|
||||
}),
|
||||
queryFn: () => fetchCandidates({
|
||||
limit: pageSize,
|
||||
offset: skip,
|
||||
search,
|
||||
assignedJobPostId: jobId || undefined,
|
||||
assignment: assignmentParam,
|
||||
}),
|
||||
})
|
||||
const jobsQuery = useQuery({
|
||||
|
|
@ -399,8 +409,6 @@ function RecruiterCandidates() {
|
|||
const rows = useMemo(() => {
|
||||
const f = filters
|
||||
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.band === 'Unscored' && c.aiScore != null) return false
|
||||
if (f.band && f.band !== 'Unscored' && recommendationOf(c) !== f.band) return false
|
||||
|
|
@ -608,7 +616,7 @@ function RecruiterCandidates() {
|
|||
GET /candidate/fetch. */}
|
||||
<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="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>
|
||||
|
|
|
|||
|
|
@ -10,19 +10,14 @@
|
|||
job. Read live from their application rather than copied
|
||||
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:
|
||||
|
||||
Match free, deterministic keyword overlap against the job picked in
|
||||
"Rank against job". It orders the pile. It is not an assessment.
|
||||
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.
|
||||
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
|
||||
CV links like any other application), then scored. Silver medalists stay on
|
||||
their inbox application and score via score_inbox when a message id exists.
|
||||
============================================================ */
|
||||
|
||||
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 Modal from '../ui/Modal'
|
||||
|
|
@ -30,6 +25,7 @@ import OpenResumeButton from '../ui/OpenResumeButton'
|
|||
import PageHeader from '../ui/PageHeader'
|
||||
import { DataTableHead, DEFAULT_PAGE_SIZE, Pagination, pageWindow, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||
import { PickRoleModal } from '../ui/SuggestedRoles'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
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
|
||||
otherwise make one row taller than the rest of the page. */
|
||||
const SKILL_CHIPS = 4
|
||||
const SUGGESTED_CHIPS = 3
|
||||
|
||||
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({
|
||||
top: limit,
|
||||
skip: offset,
|
||||
|
|
@ -66,7 +63,6 @@ async function fetchBank({ limit, offset, search, filters, jobPostId }) {
|
|||
source: filters.source || undefined,
|
||||
band: filters.band || undefined,
|
||||
minYears: filters.years ? Number(filters.years) : undefined,
|
||||
jobPostId: jobPostId || undefined,
|
||||
})
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
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 }) {
|
||||
if (score == null) return <span className="text-muted text-sm">Not scored</span>
|
||||
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() {
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const [params, setParams] = useSearchParams()
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -126,19 +130,8 @@ export default function CvBank() {
|
|||
const [skip, setSkip] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
const [preview, setPreview] = useState(null) // { name, url } — object URL we own
|
||||
const [scoreFor, setScoreFor] = useState(null)
|
||||
const [assignFor, setAssignFor] = useState(null)
|
||||
|
||||
/* 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)
|
||||
}
|
||||
const [pickingRow, setPickingRow] = useState(null)
|
||||
const [pickedById, setPickedById] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||||
|
|
@ -147,15 +140,12 @@ export default function CvBank() {
|
|||
useEffect(() => { setSkip(0) }, [search])
|
||||
|
||||
const bankQuery = useQuery({
|
||||
queryKey: qk.cvBank.list({ limit: pageSize, offset: skip, search, ...filters, jobPostId }),
|
||||
queryFn: () => fetchBank({ 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 }),
|
||||
})
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||
|
||||
const rows = useMemo(() => bankQuery.data?.rows ?? [], [bankQuery.data])
|
||||
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 from = total ? skip + 1 : 0
|
||||
|
|
@ -173,8 +163,9 @@ export default function CvBank() {
|
|||
{ key: 'title', label: 'Role', sortable: true },
|
||||
{ key: 'years', label: 'Years', sortable: true },
|
||||
{ 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: 'job', label: 'Job', sortable: false },
|
||||
{ key: 'added', label: 'Added', sortable: true },
|
||||
{ key: 'actions', label: '', sortable: false },
|
||||
], [])
|
||||
|
|
@ -196,33 +187,32 @@ export default function CvBank() {
|
|||
onError: (err) => toast(friendlyAuthError(err, 'Could not remove the CV'), 'error'),
|
||||
})
|
||||
|
||||
const scoring = useMutation({
|
||||
mutationFn: ({ jobId, ids }) => candidatesApi.scoreCvBank(jobId, ids),
|
||||
onSuccess: (res) => {
|
||||
const row = Array.isArray(res?.data) ? res.data[0] : null
|
||||
if (row?.status === 'completed') {
|
||||
toast(`Scored ${row.match_score}/100 — the result is on Candidates now`, 'success')
|
||||
const runAts = useMutation({
|
||||
mutationFn: async ({ row, jobId }) => {
|
||||
if (row.isStoredCv) {
|
||||
await candidatesApi.assignMatchingJob(row.recordId, jobId)
|
||||
return candidatesApi.scoreCvBank(jobId, [row.recordId])
|
||||
}
|
||||
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 {
|
||||
toast(`Could not score the CV${row?.error_code ? ` — ${row.error_code}` : ''}`, 'warning')
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: qk.cvBank.all() })
|
||||
qc.invalidateQueries({ queryKey: qk.candidates.all() })
|
||||
setScoreFor(null)
|
||||
qc.invalidateQueries({ queryKey: qk.pipeline.all() })
|
||||
},
|
||||
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) {
|
||||
if (!row.isStoredCv) {
|
||||
if (row.userId) navigate(`/candidate/${row.userId}`)
|
||||
|
|
@ -269,7 +259,7 @@ export default function CvBank() {
|
|||
await exportStyledXlsx({
|
||||
filename: `cv-bank-${new Date().toISOString().slice(0, 10)}`,
|
||||
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: [
|
||||
{ header: 'Name', key: 'name', width: 26 },
|
||||
{ header: 'Email', key: 'email', width: 30 },
|
||||
|
|
@ -278,12 +268,11 @@ export default function CvBank() {
|
|||
{ header: 'Company', key: 'company', width: 24 },
|
||||
{ header: 'Years', key: 'years', width: 8 },
|
||||
{ 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: 'Scored job', key: 'scoredJob', width: 24 },
|
||||
{ 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) => ({
|
||||
name: r.name,
|
||||
email: r.email || '',
|
||||
|
|
@ -292,8 +281,9 @@ export default function CvBank() {
|
|||
company: r.company || '',
|
||||
years: r.years ?? '',
|
||||
skills: r.skills.join(', '),
|
||||
match: r.rankScore ?? '',
|
||||
suggested: r.suggestedJobs.map((j) => j.title).filter(Boolean).join(', '),
|
||||
ats: r.aiScore ?? '',
|
||||
scoredJob: jobFor(r, pickedById)?.title || '',
|
||||
added: r.added ? r.added.toLocaleDateString() : '',
|
||||
})),
|
||||
})
|
||||
|
|
@ -309,7 +299,7 @@ export default function CvBank() {
|
|||
title="CV Bank"
|
||||
sub={
|
||||
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'
|
||||
}
|
||||
actions={<>
|
||||
|
|
@ -337,20 +327,6 @@ export default function CvBank() {
|
|||
<Icon name="filter" /> Filters
|
||||
</button>
|
||||
<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>
|
||||
|
||||
{showFilters && (
|
||||
|
|
@ -418,100 +394,124 @@ export default function CvBank() {
|
|||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
t.pageRows.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="cell-primary">{r.name}</div>
|
||||
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
|
||||
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}>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={r.name} initials={initialsOf(r.name)} color={avatarColor(r.name)} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="cell-primary">{r.name}</div>
|
||||
<div className="cell-sub">{r.email || r.fileName || 'No email detected'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
|
||||
{r.lastJobTitle && (
|
||||
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
|
||||
applied for {r.lastJobTitle}
|
||||
</div>
|
||||
)}
|
||||
{r.expiresAt && (
|
||||
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
|
||||
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
|
||||
</td>
|
||||
<td>
|
||||
{r.skills.length ? (
|
||||
<div className="k-tags">
|
||||
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
|
||||
<span className="tag" key={s}>{s}</span>
|
||||
))}
|
||||
{r.skills.length > SKILL_CHIPS && (
|
||||
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
|
||||
+{r.skills.length - SKILL_CHIPS}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted text-sm">None extracted</span>
|
||||
)}
|
||||
</td>
|
||||
<td><MatchCell rank={r.rankScore} hasJob={Boolean(jobPostId)} /></td>
|
||||
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||||
<td>
|
||||
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
||||
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
|
||||
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
{r.isStoredCv && (<>
|
||||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<Badge className={SOURCE_BADGE[r.source] || 'b-gray'}>{r.sourceLabel}</Badge>
|
||||
{r.lastJobTitle && (
|
||||
<div className="cell-sub cell-clip" title={r.lastJobTitle}>
|
||||
applied for {r.lastJobTitle}
|
||||
</div>
|
||||
)}
|
||||
{r.expiresAt && (
|
||||
<div className="cell-sub">{candidatesApi.expiryLabel(r.expiresAt)}</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm cell-clip" title={r.title || undefined}>{r.title || '—'}</div>
|
||||
{r.company && <div className="cell-sub cell-clip" title={r.company}>{r.company}</div>}
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{r.years == null ? '—' : r.years}</span>
|
||||
</td>
|
||||
<td>
|
||||
{r.skills.length ? (
|
||||
<div className="k-tags">
|
||||
{r.skills.slice(0, SKILL_CHIPS).map((s) => (
|
||||
<span className="tag" key={s}>{s}</span>
|
||||
))}
|
||||
{r.skills.length > SKILL_CHIPS && (
|
||||
<span className="tag" title={r.skills.slice(SKILL_CHIPS).join(', ')}>
|
||||
+{r.skills.length - SKILL_CHIPS}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted text-sm">None extracted</span>
|
||||
)}
|
||||
</td>
|
||||
<td><SuggestedJobsCell jobs={r.suggestedJobs} /></td>
|
||||
<td><AtsCell score={r.aiScore} recommendation={r.recommendation} /></td>
|
||||
<td>
|
||||
<div className="flex items-center gap-8" style={{ flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Score against a job"
|
||||
aria-label="Score this CV against a job"
|
||||
onClick={() => setScoreFor(r)}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setPickingRow(r)}
|
||||
title={picked ? `ATS job: ${picked.title}` : 'Pick a job to score against'}
|
||||
>
|
||||
<Icon name="target" />
|
||||
{picked?.title || 'Pick a job'}
|
||||
</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
|
||||
className="act-btn"
|
||||
data-tip="Remove"
|
||||
aria-label="Remove CV from bank"
|
||||
disabled={removing.isPending}
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={runDisabled}
|
||||
title={runTitle}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
|
||||
removing.mutate(r.recordId)
|
||||
}
|
||||
if (!picked || runDisabled) return
|
||||
runAts.mutate({
|
||||
row: r,
|
||||
jobId: picked.id,
|
||||
jobTitle: picked.title,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
{scoringThis ? 'Scoring…' : 'Run ATS'}
|
||||
</button>
|
||||
</>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm">{r.added ? r.added.toLocaleDateString() : '—'}</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex items-center gap-8" style={{ flexShrink: 0 }}>
|
||||
{r.isStoredCv && <OpenResumeButton filePath={r.filePath} className="btn btn-secondary btn-sm" />}
|
||||
<button className="act-btn" data-tip="View" aria-label="View CV" onClick={() => view(r)}>
|
||||
<Icon name="eye" />
|
||||
</button>
|
||||
{r.isStoredCv && (<>
|
||||
<button className="act-btn" data-tip="Download" aria-label="Download CV" onClick={() => download(r)}>
|
||||
<Icon name="download" />
|
||||
</button>
|
||||
{!r.assignedJobPostId && (
|
||||
<button
|
||||
className="act-btn"
|
||||
data-tip="Remove"
|
||||
aria-label="Remove CV from bank"
|
||||
disabled={removing.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Remove “${r.fileName || r.name}” from the CV Bank? The file is deleted permanently.`)) {
|
||||
removing.mutate(r.recordId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
)}
|
||||
</>)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -533,36 +533,23 @@ export default function CvBank() {
|
|||
</div>
|
||||
|
||||
<p className="text-muted text-sm mt-18">
|
||||
<Icon name="info" /> <strong>Match</strong> is free keyword overlap against the selected
|
||||
job — it orders this list, it does not assess anyone. <strong>ATS</strong> is a real
|
||||
scored result and only appears once someone runs one.
|
||||
<Icon name="info" /> Pick a job on a row, then <strong>Run ATS</strong> for a
|
||||
real scored result. Speculative CVs are linked to that job; silver medalists
|
||||
stay on their existing application.
|
||||
</p>
|
||||
|
||||
{scoreFor && (
|
||||
<JobPickerModal
|
||||
title="Score against a job"
|
||||
subtitle={`Run the real ATS score for ${scoreFor.name}`}
|
||||
note="This calls the scoring model and costs money. The result lands in Candidates like any other scored CV."
|
||||
confirmLabel="Score CV"
|
||||
jobs={jobs}
|
||||
defaultJobId={jobPostId}
|
||||
pending={scoring.isPending}
|
||||
onClose={() => setScoreFor(null)}
|
||||
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 })}
|
||||
{pickingRow && (
|
||||
<PickRoleModal
|
||||
title="Pick a job"
|
||||
subtitle="Search open job posts to score this CV against"
|
||||
onClose={() => setPickingRow(null)}
|
||||
onPick={(post) => {
|
||||
if (!post?.id) return
|
||||
setPickedById((m) => ({
|
||||
...m,
|
||||
[pickingRow.id]: { id: String(post.id), title: post.title || 'Selected job' },
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -598,42 +585,3 @@ function Facet({ label, value, onChange, any, options, labels }) {
|
|||
</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 { useToast } from '../ui/Toast'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
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 sheetApi from '../api/sheet'
|
||||
import * as s3Api from '../api/s3'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import {
|
||||
atsRecommendationClass, avatarColor, initials as initialsOf,
|
||||
inboxSources, sourceMeta,
|
||||
} 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. */
|
||||
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. */
|
||||
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.
|
||||
*/
|
||||
const TAB_FILTERS = {
|
||||
'Suggested Match': { hasSuggestions: true },
|
||||
Unread: { isread: false },
|
||||
Processed: { processingState: 'processed' },
|
||||
'On-Hold': { noSuggestions: true },
|
||||
|
|
@ -186,6 +187,7 @@ const TAB_FILTERS = {
|
|||
}
|
||||
|
||||
const FORM_TAB_FILTERS = {
|
||||
'Suggested Match': { has_suggestions: true },
|
||||
Processed: { processing_state: 'processed' },
|
||||
'On-Hold': { no_suggestions: true },
|
||||
Rejected: { processing_state: 'rejected' },
|
||||
|
|
@ -436,6 +438,7 @@ function mapFormRow(row) {
|
|||
noticePeriod: row.notice_period || '',
|
||||
currentSalary: row.current_salary || '',
|
||||
expectedSalary: row.expected_salary || '',
|
||||
experience: (row.experience || row.experience_details || '').trim(),
|
||||
profileLink: row.profile_link || '',
|
||||
resumeLink: row.resume_link || '',
|
||||
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()
|
||||
}
|
||||
|
||||
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. */
|
||||
const RESUME_STATUS = {
|
||||
processing: 'Parsing', matched: 'Parsed', no_text: 'Failed',
|
||||
|
|
@ -566,6 +585,15 @@ async function fetchMessageDetail(recordId) {
|
|||
initials: initialsOf(name),
|
||||
color: avatarColor(name),
|
||||
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)',
|
||||
...sourceFrom(row.message_to),
|
||||
received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time),
|
||||
|
|
@ -580,7 +608,6 @@ async function fetchMessageDetail(recordId) {
|
|||
bodyHtml: row.body || '',
|
||||
cc: row.message_cc || '',
|
||||
bcc: row.message_bcc || '',
|
||||
sentAt: parseGraphDate(row.message_sent_time),
|
||||
files: Array.isArray(row.files) ? row.files : [],
|
||||
filePath: row.file_path || '',
|
||||
linkedinSlug: row.linkedin_slug || '',
|
||||
|
|
@ -646,7 +673,11 @@ async function fetchApplications(params) {
|
|||
atsScore: emailAtsScore(row),
|
||||
phone: row.phone,
|
||||
experience: row.experience,
|
||||
currentTitle: extractedText(row.current_title),
|
||||
currentCompany: extractedText(row.current_employment),
|
||||
education: extractedText(row.education),
|
||||
recruiter: row.recruiter,
|
||||
recruiterId: row.recruiter_id || null,
|
||||
city: row.city || '',
|
||||
residingCity: row.city || '',
|
||||
duplicate: Boolean(row.duplicate),
|
||||
|
|
@ -1186,8 +1217,6 @@ export default function Inbox() {
|
|||
const qc = useQueryClient()
|
||||
const { can } = useAuth()
|
||||
const canEdit = can('inbox.edit')
|
||||
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
|
||||
const updateInbox = useSeedMutation('inbox')
|
||||
|
||||
const [channel, setChannel] = useState('all')
|
||||
const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET)
|
||||
|
|
@ -1446,6 +1475,7 @@ export default function Inbox() {
|
|||
: n((isForms ? f : e)[key]))
|
||||
return {
|
||||
'All Applications': pick('all'),
|
||||
'Suggested Match': pick('suggested'),
|
||||
Unread: pick('unread'),
|
||||
Processed: pick('processed'),
|
||||
'On-Hold': pick('on_hold'),
|
||||
|
|
@ -1524,6 +1554,10 @@ export default function Inbox() {
|
|||
...(detailQuery.data ?? {}),
|
||||
received: selectedRow?.received ?? detailQuery.data?.received ?? 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
|
||||
|
||||
|
|
@ -2099,6 +2133,9 @@ export default function Inbox() {
|
|||
{(i.city || i.residingCity) && (
|
||||
<span className="cell-sub">{i.city || i.residingCity}</span>
|
||||
)}
|
||||
{orDash(i.phone) !== '—' && (
|
||||
<span className="cell-sub">{i.phone}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -2182,6 +2219,7 @@ export default function Inbox() {
|
|||
onNote={() => setNoting(selected)}
|
||||
onReject={() => reject(selected)}
|
||||
onToggleDuplicate={() => toggleDuplicate(selected)}
|
||||
onAssignRecruiter={setAssigning}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -2193,13 +2231,8 @@ export default function Inbox() {
|
|||
{assigning && (
|
||||
<AssignRecruiter
|
||||
item={assigning}
|
||||
recruiters={recruiters}
|
||||
toast={toast}
|
||||
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". */
|
||||
const PHONE_PLACEHOLDER = /^xxx-xxx-xxxx$/i
|
||||
|
||||
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) {
|
||||
|
|
@ -2465,6 +2504,7 @@ function FormApplicantDetail({
|
|||
<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">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">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>
|
||||
|
|
@ -2624,7 +2664,7 @@ function FormApplicantDetail({
|
|||
}
|
||||
|
||||
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 recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
|
||||
|
|
@ -2735,7 +2775,10 @@ function ApplicationDetail({
|
|||
{i.name}
|
||||
<ReappliedBadge row={i} />
|
||||
</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 }}>
|
||||
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
|
||||
{i.duplicate && <><Badge className="b-red">Duplicate</Badge>{' '}</>}
|
||||
|
|
@ -2763,37 +2806,41 @@ function ApplicationDetail({
|
|||
|
||||
<PreviousApplications row={i} />
|
||||
|
||||
{(resumeKey || i.hasAttachment || profileHref) && (
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{(resumeKey || i.hasAttachment) && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={openResume.isPending || !canOpenResume}
|
||||
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
|
||||
>
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</button>
|
||||
)}
|
||||
{profileHref && (
|
||||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-8" style={{ flexWrap: 'wrap', marginBottom: 18 }}>
|
||||
{(resumeKey || i.hasAttachment) && (
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={openResume.isPending || !canOpenResume}
|
||||
onClick={() => openResume.mutate(window.open('about:blank', '_blank'))}
|
||||
>
|
||||
<Icon name="paperclip" /> Open resume
|
||||
</button>
|
||||
)}
|
||||
{profileHref && (
|
||||
<a className="btn btn-secondary btn-sm" href={profileHref} target="_blank" rel="noopener noreferrer">
|
||||
<Icon name="linkedin" /> LinkedIn
|
||||
</a>
|
||||
)}
|
||||
{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-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">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">Received</div>
|
||||
<div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</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.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}
|
||||
{i.atsScore != null && (
|
||||
|
|
@ -2853,11 +2900,13 @@ function ApplicationDetail({
|
|||
gap: 18,
|
||||
alignItems: 'start',
|
||||
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 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="email-pane" style={{ marginBottom: 16 }}>
|
||||
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
|
||||
{looksLikeHtml(i.bodyHtml) ? (
|
||||
<EmailBody html={i.bodyHtml} />
|
||||
|
|
@ -3019,9 +3068,30 @@ function ApplicationDetail({
|
|||
)
|
||||
}
|
||||
|
||||
function AssignRecruiter({ item, recruiters, onClose, onSave }) {
|
||||
const [name, setName] = useState(item.recruiter)
|
||||
const current = recruiters.find((r) => r.name === item.recruiter)
|
||||
function AssignRecruiter({ item, toast, onClose }) {
|
||||
const qc = useQueryClient()
|
||||
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 (
|
||||
<Modal
|
||||
title="Assign Recruiter"
|
||||
|
|
@ -3030,19 +3100,25 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
|
|||
footer={
|
||||
<>
|
||||
<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">
|
||||
<label>Recruiter</label>
|
||||
<select value={name} onChange={(e) => setName(e.target.value)}>
|
||||
{recruiters.map((r) => <option key={r.id}>{r.name}</option>)}
|
||||
<label htmlFor="inbox-assign-recruiter">Recruiter</label>
|
||||
<select
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,7 +282,7 @@ export default function Jobs() {
|
|||
{ 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: '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,
|
||||
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() {
|
||||
return useQuery({
|
||||
queryKey: qk.managers.directory(),
|
||||
|
|
@ -593,7 +700,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
|
||||
const form = useFormState({
|
||||
hiring_manager_id: '',
|
||||
current_recruiter_id: '',
|
||||
current_recruiter_ids: [],
|
||||
requisition_id: '',
|
||||
title: '',
|
||||
department: '',
|
||||
|
|
@ -681,7 +788,7 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
optional_skills: splitLines(v.optional_skills),
|
||||
description: v.description.trim() || null,
|
||||
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,
|
||||
}, imageFile)
|
||||
}
|
||||
|
|
@ -790,16 +897,14 @@ function JobForm({ departmentOptions, busy, onClose, onSubmit }) {
|
|||
)}
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiter</label>
|
||||
<SearchSelect
|
||||
<label>Recruiters</label>
|
||||
<RecruiterMultiSelect
|
||||
options={recruitersQuery.data ?? []}
|
||||
value={form.values.current_recruiter_id}
|
||||
onChange={(id) => form.setField('current_recruiter_id', id)}
|
||||
value={form.values.current_recruiter_ids}
|
||||
onChange={(ids) => form.setField('current_recruiter_ids', ids)}
|
||||
placeholder="Search recruiters…"
|
||||
disabled={busy}
|
||||
loading={recruitersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
{recruitersQuery.isError && (
|
||||
<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) : '',
|
||||
description: j.description || '',
|
||||
hiring_manager_id: j.hiringManagerId || '',
|
||||
current_recruiter_id: j.recruiterId || '',
|
||||
current_recruiter_ids: j.recruiterIds || (j.recruiterId ? [j.recruiterId] : []),
|
||||
})
|
||||
|
||||
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),
|
||||
description: form.values.description.trim() || 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,
|
||||
})
|
||||
}
|
||||
|
|
@ -1094,16 +1199,14 @@ function EditJobForm({ job: j, departmentOptions, busy, onClose, onSubmit }) {
|
|||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiter</label>
|
||||
<SearchSelect
|
||||
<label>Recruiters</label>
|
||||
<RecruiterMultiSelect
|
||||
options={recruitersQuery.data ?? []}
|
||||
value={form.values.current_recruiter_id}
|
||||
onChange={(id) => form.setField('current_recruiter_id', id)}
|
||||
value={form.values.current_recruiter_ids}
|
||||
onChange={(ids) => form.setField('current_recruiter_ids', ids)}
|
||||
placeholder="Search recruiters…"
|
||||
disabled={busy}
|
||||
loading={recruitersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
|
|
@ -1202,21 +1305,20 @@ function JobOwnership({ job, canEdit }) {
|
|||
)}
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label>Recruiter</label>
|
||||
<label>Recruiters</label>
|
||||
{canEdit ? (
|
||||
<SearchSelect
|
||||
<RecruiterMultiSelect
|
||||
options={recruitersQuery.data ?? []}
|
||||
value={job.recruiterId || ''}
|
||||
onChange={(id) => {
|
||||
const next = id || null
|
||||
if (String(next || '') === String(job.recruiterId || '')) return
|
||||
patch.mutate({ current_recruiter_id: next })
|
||||
value={job.recruiterIds || (job.recruiterId ? [job.recruiterId] : [])}
|
||||
onChange={(ids) => {
|
||||
const next = (ids || []).filter(Boolean).map(String)
|
||||
const current = job.recruiterIds || (job.recruiterId ? [String(job.recruiterId)] : [])
|
||||
if (sameIdList(next, current)) return
|
||||
patch.mutate({ current_recruiter_ids: next })
|
||||
}}
|
||||
placeholder="Search recruiters…"
|
||||
disabled={patch.isPending}
|
||||
loading={recruitersQuery.isPending}
|
||||
allowEmpty
|
||||
emptyLabel="Unassigned"
|
||||
/>
|
||||
) : (
|
||||
<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">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">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>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
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 { 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. */
|
||||
export const KANBAN_STAGES = [
|
||||
{ name: 'CLOSED', color: 'var(--stage-9)' },
|
||||
{ name: 'Shortlist', color: 'var(--stage-1)' },
|
||||
{ name: 'Screening', color: 'var(--stage-2)' },
|
||||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||
|
|
@ -43,6 +44,7 @@ export const KANBAN_STAGES = [
|
|||
|
||||
const BOARD_LIMIT = 200
|
||||
const JOB_LIMIT = 100
|
||||
const SEARCH_DEBOUNCE_MS = 300
|
||||
|
||||
/**
|
||||
* Highest AI score first, unscored candidates last, newest first within a tie.
|
||||
|
|
@ -73,8 +75,12 @@ function mapCards(rows, mapper) {
|
|||
return cards
|
||||
}
|
||||
|
||||
async function fetchBoard(jobId) {
|
||||
const res = await pipelineApi.listApplications({ jobId, limit: BOARD_LIMIT })
|
||||
async function fetchBoard(jobId, search) {
|
||||
const res = await pipelineApi.listApplications({
|
||||
jobId,
|
||||
limit: BOARD_LIMIT,
|
||||
search: search || undefined,
|
||||
})
|
||||
const inbox = Array.isArray(res?.data?.inbox) ? res.data.inbox : []
|
||||
const manuals = Array.isArray(res?.data?.manual_upload) ? res.data.manual_upload : []
|
||||
return {
|
||||
|
|
@ -113,17 +119,24 @@ export default function Pipeline() {
|
|||
const qc = useQueryClient()
|
||||
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [draggingId, setDraggingId] = useState(null)
|
||||
const [overStage, setOverStage] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setSearch(q.trim()), SEARCH_DEBOUNCE_MS)
|
||||
return () => clearTimeout(t)
|
||||
}, [q])
|
||||
|
||||
const boardKey = useMemo(
|
||||
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null }),
|
||||
[jobId],
|
||||
() => qk.pipeline.board({ limit: BOARD_LIMIT, jobId: jobId || null, search: search || null }),
|
||||
[jobId, search],
|
||||
)
|
||||
|
||||
const board = useQuery({
|
||||
queryKey: boardKey,
|
||||
queryFn: () => fetchBoard(jobId),
|
||||
queryFn: () => fetchBoard(jobId, search),
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
const jobsQuery = useQuery({
|
||||
|
|
@ -221,6 +234,15 @@ export default function Pipeline() {
|
|||
{total > candidates.length && ` · showing ${candidates.length} of ${total} applications`}
|
||||
</>}
|
||||
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)}>
|
||||
<option value="">All Jobs</option>
|
||||
{jobs.map((j) => (
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ export default function Progress() {
|
|||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [sortBy, setSortBy] = useState('applicants')
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||
|
||||
|
|
@ -239,9 +240,11 @@ export default function Progress() {
|
|||
|
||||
const filtered = useMemo(() => {
|
||||
const term = query.trim().toLowerCase()
|
||||
const sortFilter = sortBy === 'completed' ? 'completed' : status
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
if (status !== 'all' && String(job.requisitionStatus || '').toLowerCase() !== status) {
|
||||
const req = String(job.requisitionStatus || '').toLowerCase()
|
||||
if (sortFilter !== 'all' && req !== sortFilter) {
|
||||
return false
|
||||
}
|
||||
if (!term) return true
|
||||
|
|
@ -253,7 +256,7 @@ export default function Progress() {
|
|||
)
|
||||
})
|
||||
.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 currentPage = Math.min(page, pages)
|
||||
|
|
@ -264,7 +267,7 @@ export default function Progress() {
|
|||
|
||||
useEffect(() => {
|
||||
setPage(1)
|
||||
}, [query, status])
|
||||
}, [query, status, sortBy])
|
||||
|
||||
useEffect(() => {
|
||||
setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize))
|
||||
|
|
@ -372,8 +375,17 @@ export default function Progress() {
|
|||
<option value="open">Open</option>
|
||||
<option value="on_hold">On hold</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>
|
||||
<span className="progress-sort-chip">Sort: applicants</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const RANGES = [
|
|||
]
|
||||
|
||||
/* 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. */
|
||||
const FUNNEL_ORDER = [
|
||||
{ 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; }
|
||||
.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 ================= */
|
||||
.list-tight > * + * { border-top: 1px solid var(--border); }
|
||||
.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 { 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-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),
|
||||
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. */
|
||||
|
|
@ -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
|
||||
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. */
|
||||
.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; }
|
||||
.email-frame { display: block; width: 100%; border: 1px solid var(--border); border-radius: 0 0 10px 10px; background: var(--bg-sunken); }
|
||||
.email-plain { border-radius: 0 0 10px 10px; }
|
||||
plain text — both square off their top corners to meet the header. Height
|
||||
follows the text; EmailBody measures the frame so HTML mail is not a 420px
|
||||
empty well. */
|
||||
.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 */
|
||||
/* Job detail cover image — banner above the info grid. */
|
||||
|
|
|
|||
|
|
@ -68,6 +68,22 @@ function sanitize(html) {
|
|||
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 || ''
|
||||
}
|
||||
|
||||
|
|
@ -85,18 +101,39 @@ function frameStyles() {
|
|||
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
return `
|
||||
:root { color-scheme: ${dark ? 'dark' : 'light'}; }
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
background: ${pick('--bg-sunken', '#f7f7f8')};
|
||||
color: ${pick('--text', '#111')};
|
||||
font-family: ${pick('--sans', 'system-ui, sans-serif')};
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
overflow-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
img, table { max-width: 100%; }
|
||||
img { height: auto; }
|
||||
table { border-collapse: collapse; }
|
||||
img, svg, video, canvas {
|
||||
max-width: 100% !important;
|
||||
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')}; }
|
||||
blockquote {
|
||||
margin: 8px 0; padding-left: 12px;
|
||||
|
|
@ -123,10 +160,12 @@ export function looksLikeHtml(value) {
|
|||
return /<[a-z!/][\s\S]*>/i.test(String(value || ''))
|
||||
}
|
||||
|
||||
const MIN_FRAME_HEIGHT = 48
|
||||
|
||||
export default function EmailBody({ html, maxHeight }) {
|
||||
const ref = useRef(null)
|
||||
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
|
||||
const [height, setHeight] = useState(320)
|
||||
const [height, setHeight] = useState(MIN_FRAME_HEIGHT)
|
||||
const [blockedImages, setBlockedImages] = useState(0)
|
||||
const themeVersion = useThemeVersion()
|
||||
|
||||
|
|
@ -147,9 +186,17 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
const measure = useCallback(() => {
|
||||
const frame = ref.current
|
||||
// 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
|
||||
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(() => {
|
||||
|
|
@ -164,13 +211,27 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
doc.querySelectorAll('img').forEach((i) => i.addEventListener('load', measure))
|
||||
}, [measure, allowRemoteImages])
|
||||
|
||||
useEffect(() => {
|
||||
setHeight(MIN_FRAME_HEIGHT)
|
||||
}, [html])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', measure)
|
||||
return () => window.removeEventListener('resize', 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 (
|
||||
<div>
|
||||
<div style={{ minWidth: 0, maxWidth: '100%', overflow: 'hidden' }}>
|
||||
{blockedImages > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-8"
|
||||
|
|
@ -200,7 +261,13 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
// No allow-scripts. Ever. See the header comment.
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 { data = [], isPending, isError, error } = useQuery({
|
||||
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
|
||||
|
|
@ -159,8 +164,8 @@ export function PickRoleModal({ onClose, onPick }) {
|
|||
|
||||
return (
|
||||
<Modal
|
||||
title="Choose a different role"
|
||||
subtitle="Search open job posts"
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
size="modal-lg"
|
||||
onClose={onClose}
|
||||
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
|
||||
|
|
|
|||
Loading…
Reference in New Issue