diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 41cc9a5..90b5310 100644 --- a/backend/employment_agent/decorators.py +++ b/backend/employment_agent/decorators.py @@ -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"), diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index b240284..dfe2da1 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -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, diff --git a/backend/employment_agent/plugins.py b/backend/employment_agent/plugins.py index 05b1904..01a3aef 100644 --- a/backend/employment_agent/plugins.py +++ b/backend/employment_agent/plugins.py @@ -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"(? 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): diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 5dfd420..18aea05 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -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", diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index 012698d..b98227f 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -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: diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 236c31b..4a5995b 100644 --- a/backend/g_sheet/models.py +++ b/backend/g_sheet/models.py @@ -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 diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 423b768..f95ff5d 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -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): diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 029cd06..79a712b 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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 diff --git a/backend/inbox/models.py b/backend/inbox/models.py index b885910..bcd4048 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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) diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index c2c63d8..f87c26a 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -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, diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index 5c4b42a..c52afd2 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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, diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 37f5f9f..d254a50 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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) diff --git a/backend/job/app.py b/backend/job/app.py index a84023c..31ff34c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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 diff --git a/backend/job/assignment/models.py b/backend/job/assignment/models.py index 371a703..7e8876f 100644 --- a/backend/job/assignment/models.py +++ b/backend/job/assignment/models.py @@ -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) diff --git a/backend/job/assignment/views.py b/backend/job/assignment/views.py index 99146fe..454d29a 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -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) diff --git a/backend/job/candidate/bank_tasks.py b/backend/job/candidate/bank_tasks.py index 5a07b21..0baadd4 100644 --- a/backend/job/candidate/bank_tasks.py +++ b/backend/job/candidate/bank_tasks.py @@ -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) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 05a8875..5962b09 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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 diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index cd3729d..0cdd6a7 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -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, } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index e000ede..f8bf6be 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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 []) diff --git a/backend/job/cost/models.py b/backend/job/cost/models.py index 00d8d41..e21670d 100644 --- a/backend/job/cost/models.py +++ b/backend/job/cost/models.py @@ -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 diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 2708396..112365f 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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 diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 608f785..1adb195 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -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), diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 1965178..3f4f408 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -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) diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py index f3128d5..9568029 100644 --- a/backend/job/pipeline/views.py +++ b/backend/job/pipeline/views.py @@ -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}, diff --git a/backend/migrations/manual/035_job_post_recruiter_ids.sql b/backend/migrations/manual/035_job_post_recruiter_ids.sql new file mode 100644 index 0000000..d627d77 --- /dev/null +++ b/backend/migrations/manual/035_job_post_recruiter_ids.sql @@ -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); diff --git a/backend/notifications/views.py b/backend/notifications/views.py index 472e373..0680586 100644 --- a/backend/notifications/views.py +++ b/backend/notifications/views.py @@ -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", diff --git a/backend/offer/models.py b/backend/offer/models.py index 6cd8870..e914fae 100644 --- a/backend/offer/models.py +++ b/backend/offer/models.py @@ -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 diff --git a/backend/tests/test_application_history.py b/backend/tests/test_application_history.py index bcd6850..a6ffd5b 100644 --- a/backend/tests/test_application_history.py +++ b/backend/tests/test_application_history.py @@ -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" diff --git a/backend/tests/test_cv_bank_serialize.py b/backend/tests/test_cv_bank_serialize.py new file mode 100644 index 0000000..d8d0911 --- /dev/null +++ b/backend/tests/test_cv_bank_serialize.py @@ -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 diff --git a/backend/tests/test_employment_extraction_clamps.py b/backend/tests/test_employment_extraction_clamps.py index 858e1ee..161c059 100644 --- a/backend/tests/test_employment_extraction_clamps.py +++ b/backend/tests/test_employment_extraction_clamps.py @@ -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"] == "" diff --git a/fix_inbox_cities.py b/fix_inbox_cities.py new file mode 100644 index 0000000..1cc5b43 --- /dev/null +++ b/fix_inbox_cities.py @@ -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() diff --git a/frontend/candidates-table.test.mjs b/frontend/candidates-table.test.mjs index afdb05b..8965873 100644 --- a/frontend/candidates-table.test.mjs +++ b/frontend/candidates-table.test.mjs @@ -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, diff --git a/frontend/cvbank.test.mjs b/frontend/cvbank.test.mjs index 9c676bf..2afc6dd 100644 --- a/frontend/cvbank.test.mjs +++ b/frontend/cvbank.test.mjs @@ -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') diff --git a/frontend/reapplicant.test.mjs b/frontend/reapplicant.test.mjs new file mode 100644 index 0000000..e2baae4 --- /dev/null +++ b/frontend/reapplicant.test.mjs @@ -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') diff --git a/frontend/src/api/analytics.js b/frontend/src/api/analytics.js index 9b9f873..309c8c8 100644 --- a/frontend/src/api/analytics.js +++ b/frontend/src/api/analytics.js @@ -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( diff --git a/frontend/src/api/assignments.js b/frontend/src/api/assignments.js index b661fea..d6b2fce 100644 --- a/frontend/src/api/assignments.js +++ b/frontend/src/api/assignments.js @@ -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. ============================================================ */ diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 989dc76..c5f005c 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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 }, }) } diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index ed168d8..0dcc699 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -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' }) diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js index 04f1d54..b20432a 100644 --- a/frontend/src/api/jobStats.js +++ b/frontend/src/api/jobStats.js @@ -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, diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 9f1ce8f..1278337 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -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, diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 1e4a8c7..4e3e705 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -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 }, }) } diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index 20e3969..35a2db7 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -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 }, }) } diff --git a/frontend/src/components/ReapplicantHistory.jsx b/frontend/src/components/ReapplicantHistory.jsx index 7fd159a..1a51e80 100644 --- a/frontend/src/components/ReapplicantHistory.jsx +++ b/frontend/src/components/ReapplicantHistory.jsx @@ -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 ( { 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, diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index 918db3f..35ec9dc 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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) { diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 35a563e..01194e6 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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. */} setFilter('stage', v)} any="Any stage" options={STAGE_FILTERS} /> setFilter('band', v)} any="Any band" options={BAND_FILTERS} /> - setFilter('account', v)} any="Any account" options={['Active', 'Unconfirmed']} /> + setFilter('assignment', v)} any="Any assignment" options={['Assigned', 'Unassigned']} /> )} diff --git a/frontend/src/screens/CvBank.jsx b/frontend/src/screens/CvBank.jsx index 70cf1bf..454009f 100644 --- a/frontend/src/screens/CvBank.jsx +++ b/frontend/src/screens/CvBank.jsx @@ -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 Pick a job - if (rank == null) return - return ( -
-
{rank}/100
- - ) -} - function AtsCell({ score, recommendation }) { if (score == null) return Not scored return ( @@ -113,11 +85,43 @@ function AtsCell({ score, recommendation }) { ) } +function SuggestedJobsCell({ jobs }) { + if (!jobs?.length) return null + return ( +
+ {jobs.slice(0, SUGGESTED_CHIPS).map((j) => ( + {j.title || 'Job'} + ))} + {jobs.length > SUGGESTED_CHIPS && ( + j.title).join(', ')}> + +{jobs.length - SUGGESTED_CHIPS} + + )} +
+ ) +} + +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=") 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 {selectedJob.title} : null} + ? <>{total} CV{total === 1 ? '' : 's'} held for future roles : 'CVs held for future roles' } actions={<> @@ -337,20 +327,6 @@ export default function CvBank() { Filters
- {/* The "a role just opened, who do we already have" control. This is - the moment the bank is meant to be used. */} -
- - -
{showFilters && ( @@ -418,100 +394,124 @@ export default function CvBank() { ) : ( - t.pageRows.map((r) => ( - - -
- -
-
{r.name}
-
{r.email || r.fileName || 'No email detected'}
+ 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 ( + + +
+ +
+
{r.name}
+
{r.email || r.fileName || 'No email detected'}
+
-
- - - {r.sourceLabel} - {r.lastJobTitle && ( -
- applied for {r.lastJobTitle} -
- )} - {r.expiresAt && ( -
{candidatesApi.expiryLabel(r.expiresAt)}
- )} - - -
{r.title || '—'}
- {r.company &&
{r.company}
} - - - {r.years == null ? '—' : r.years} - - - {r.skills.length ? ( -
- {r.skills.slice(0, SKILL_CHIPS).map((s) => ( - {s} - ))} - {r.skills.length > SKILL_CHIPS && ( - - +{r.skills.length - SKILL_CHIPS} - - )} -
- ) : ( - None extracted - )} - - - - - {r.added ? r.added.toLocaleDateString() : '—'} - - -
- {r.isStoredCv && } - - {r.isStoredCv && (<> - + + + {r.sourceLabel} + {r.lastJobTitle && ( +
+ applied for {r.lastJobTitle} +
+ )} + {r.expiresAt && ( +
{candidatesApi.expiryLabel(r.expiresAt)}
+ )} + + +
{r.title || '—'}
+ {r.company &&
{r.company}
} + + + {r.years == null ? '—' : r.years} + + + {r.skills.length ? ( +
+ {r.skills.slice(0, SKILL_CHIPS).map((s) => ( + {s} + ))} + {r.skills.length > SKILL_CHIPS && ( + + +{r.skills.length - SKILL_CHIPS} + + )} +
+ ) : ( + None extracted + )} + + + + +
- - )} -
- - - )) +
+ + + {r.added ? r.added.toLocaleDateString() : '—'} + + +
+ {r.isStoredCv && } + + {r.isStoredCv && (<> + + {!r.assignedJobPostId && ( + + )} + )} +
+ + + ) + }) )} @@ -533,36 +533,23 @@ export default function CvBank() {

- Match is free keyword overlap against the selected - job — it orders this list, it does not assess anyone. ATS is a real - scored result and only appears once someone runs one. + Pick a job on a row, then Run ATS for a + real scored result. Speculative CVs are linked to that job; silver medalists + stay on their existing application.

- {scoreFor && ( - setScoreFor(null)} - onConfirm={(jobId) => scoring.mutate({ jobId, ids: [scoreFor.recordId] })} - /> - )} - - {assignFor && ( - setAssignFor(null)} - onConfirm={(jobId) => assigning.mutate({ id: assignFor.recordId, jobId })} + {pickingRow && ( + 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 }) {
) } - -/** 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 ( - - - - } - > - {jobs.length === 0 ? ( - - Create a job post first — there is nothing to match against. - - ) : ( - <> -
- - -
-

{note}

- - )} -
- ) -} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 13d7dcd..c563edd 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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) && ( {i.city || i.residingCity} )} + {orDash(i.phone) !== '—' && ( + {i.phone} + )} @@ -2182,6 +2219,7 @@ export default function Inbox() { onNote={() => setNoting(selected)} onReject={() => reject(selected)} onToggleDuplicate={() => toggleDuplicate(selected)} + onAssignRecruiter={setAssigning} /> )} @@ -2193,13 +2231,8 @@ export default function Inbox() { {assigning && ( 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({
Email
{orDash(i.email)}
Phone
{orDash(i.phone)}
+
Experience
{orDash(i.experience, ' years')}
Applied
{i.received ? fmtDateTime(i.received) : '—'}
Source
{orDash(i.source)}
Screened by
{orDash(i.screenedBy)}
@@ -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}
-
{i.position}
+
+ {[extractedText(i.currentTitle), extractedText(i.currentCompany)].filter(Boolean).join(' at ') + || i.position} +
{i.processing}{' '} {i.duplicate && <>Duplicate{' '}} @@ -2763,37 +2806,41 @@ function ApplicationDetail({ - {(resumeKey || i.hasAttachment || profileHref) && ( -
- {(resumeKey || i.hasAttachment) && ( - - )} - {profileHref && ( - - LinkedIn - - )} -
- )} +
+ {(resumeKey || i.hasAttachment) && ( + + )} + {profileHref && ( + + LinkedIn + + )} + {canEdit && ( + + )} +
Email
{orDash(i.email)}
Phone
{orDash(i.phone)}
Experience
{orDash(i.experience, ' years')}
+
Current title
{orDash(extractedText(i.currentTitle))}
+
Current company
{orDash(extractedText(i.currentCompany))}
+
Location
{orDash(i.city || i.residingCity)}
+
Education
{orDash(extractedText(i.education))}
Assigned Recruiter
{orDash(i.recruiter)}
Received
{i.received ? fmtDateTime(i.received) : '—'}
- {i.sentAt && ( -
Sent
{fmtDateTime(i.sentAt)}
- )} {i.cc &&
CC
{i.cc}
} {i.bcc &&
BCC
{i.bcc}
} {i.atsScore != null && ( @@ -2853,11 +2900,13 @@ function ApplicationDetail({ gap: 18, alignItems: 'start', marginBottom: 20, + minWidth: 0, + maxWidth: '100%', }} > -
+
{!loading && ( -
+
Subject: {i.position || '(no subject)'}
{looksLikeHtml(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 ( - + } >
- - setRecruiterId(e.target.value)} + > + + {recruiters.map((r) => ( + + ))}
-

- Current workload is factored automatically. This recruiter has {current?.openReqs ?? 5} open reqs. -

) } diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index 27f5ea2..94dfd18 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -282,7 +282,7 @@ export default function Jobs() { { key: 'vacancies', label: 'Vacancies', sortable: true, align: 'center', render: (j) => {j.vacancies ?? '—'} }, { key: 'status', label: 'Status', sortable: true, render: (j) => {j.status} }, { 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 ( +
+ {selected.length > 0 && ( +
+ {selected.map((o) => ( + + {o.name} + + + ))} +
+ )} +
+ { setOpen(true); setQ('') }} + onChange={(e) => { setQ(e.target.value); setOpen(true) }} + /> + {open && !disabled && !loading && ( +
+ {filtered.length === 0 && ( +
+ {selected.length && !term ? 'All recruiters selected' : 'No matches'} +
+ )} + {filtered.map((o) => ( + + ))} +
+ )} +
+
+ ) +} + 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 }) { )}
- - Recruiters + 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 && (

Recruiter list needs tasks.view — you can assign later.

@@ -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 }) { />
- - Recruiters + 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" />
@@ -1202,21 +1305,20 @@ function JobOwnership({ job, canEdit }) { )}
- + {canEdit ? ( - { - 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" /> ) : (

{job.recruiter || 'No recruiter assigned yet.'}

@@ -1479,7 +1581,7 @@ function JobDetail({
Created by
{j.createdByName || '—'}
Requisition
{j.requisitionLabel || '—'}
Hiring Manager
{j.hiringManager || '—'}
-
Assigned Recruiter
{j.recruiter || '—'}
+
Assigned Recruiters
{j.recruiter || '—'}
Closed at
{j.closedAt ? fmtShort(j.closedAt) : '—'}
diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 9e257d7..04176cc 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -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={<> +
+ + setQ(e.target.value)} + placeholder="Search candidate name or email…" + aria-label="Search candidates" + /> +
+ - Sort: applicants
diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx index 0f51bf5..1a93d63 100644 --- a/frontend/src/screens/Reports.jsx +++ b/frontend/src/screens/Reports.jsx @@ -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' }, diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 07ab26c..ff48d4a 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -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
 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. */
diff --git a/frontend/src/ui/EmailBody.jsx b/frontend/src/ui/EmailBody.jsx
index 443f400..49d7e1c 100644
--- a/frontend/src/ui/EmailBody.jsx
+++ b/frontend/src/ui/EmailBody.jsx
@@ -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 (
-    
+
{blockedImages > 0 && (
) diff --git a/frontend/src/ui/SuggestedRoles.jsx b/frontend/src/ui/SuggestedRoles.jsx index 99fc213..dd0ce76 100644 --- a/frontend/src/ui/SuggestedRoles.jsx +++ b/frontend/src/ui/SuggestedRoles.jsx @@ -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 ( Cancel}