Merge pull request 'SQS_BROKER' (#78) from SQS_BROKER into main
Deploy to S3 / checks (push) Failing after 1m54s Details
Deploy to S3 / deploy (push) Has been skipped Details
CI / checks (push) Failing after 2m0s Details
CI / checks (pull_request) Failing after 1m56s Details

Reviewed-on: #78
pull/79/head
ahmed.mujtaba 2026-09-07 12:54:46 +00:00
commit 9036a48098
23 changed files with 891 additions and 157 deletions

View File

@ -17,7 +17,7 @@ from __future__ import annotations
import re
from functools import wraps
from employment_agent.prompt import EDUCATION,NO_COMPANY,NO_LINKEDIN,NO_PHONE
from employment_agent.prompt import EDUCATION,NO_CITY,NO_COMPANY,NO_LINKEDIN,NO_PHONE
def require_json_object(func):
@ -102,6 +102,21 @@ def _clean_phone(value,resume_text):
return text
def _clean_city(value,resume_text):
"""Optional residence city. Sentinel / invented → None. Never rejects the CV.
The prompt forbids work-experience cities; this only drops a value that is
absent from the resume text or is the explicit empty sentinel.
"""
text=(value or "").strip()
if not text or text.lower() in (NO_CITY.lower(),"none","null","n/a","-"):
return None
haystack=(resume_text or "").lower()
if haystack and text.lower() not in haystack:
return None
return text
def _clean_skills(value,resume_text):
"""Keep only skills the resume actually contains, deduplicated, capped at 30.
@ -171,6 +186,7 @@ clamp_linkedin_url=clamp_field("linkedin_url",_clean_linkedin)
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)
@require_json_object
@ -181,8 +197,9 @@ clamp_years_experience=clamp_field("years_experience",_clean_years)
@clamp_phone
@clamp_skills
@clamp_years_experience
@clamp_city
def parse_employment_response(data,resume_text=""):
"""Pull company, education, title, linkedin_url, phone, skills, and years
"""Pull 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
@ -198,6 +215,7 @@ def parse_employment_response(data,resume_text=""):
"current_title":as_str("current_title"),
"linkedin_url":as_str("linkedin_url"),
"phone":as_str("phone"),
"city":as_str("city"),
"skills":data.get("skills") if isinstance(data.get("skills"),list) else [],
"years_experience":data.get("years_experience"),
}

View File

@ -24,6 +24,7 @@ async def run_employment_agent(*,resume_text=""):
"current_title":CURRENT_TITLE,
"linkedin_url":None,
"phone":None,
"city":None,
"skills":[],
"years_experience":None,
}

View File

@ -12,6 +12,7 @@ EDUCATION="No Education Mentioned"
CURRENT_TITLE="No JOB POSITION MENTIONED"
NO_LINKEDIN="no linkedin url mentioned"
NO_PHONE="no phone number mentioned"
NO_CITY="no city mentioned"
def prompt():
@ -19,8 +20,8 @@ def prompt():
You are given CV/resume text. Identify the candidate's CURRENT employer company
name, their education (degree / school), their current job title, their
LinkedIn profile URL, their phone number, their skills, and their total years
of professional experience, when present.
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 company name that appears in the resume text for the ongoing / most recent role.
@ -55,6 +56,13 @@ linkedin_url (its own key — extract this separately from the other fields):
- Do not invent a profile. If none is mentioned, return exactly: {NO_LINKEDIN}
- Never guess a slug or construct linkedin.com/in/<name> from the candidate's name. The stored value will be null when this sentinel is returned.
city (its own key OPTIONAL. A missing city must not fail the candidate):
- Return the city of residence only, city name alone (for example "Karachi", "Lahore", "Islamabad"), using the resume's own spelling.
- Extract city ONLY from the candidate's contact / location / address header (the block with name, phone, email, LinkedIn, "Address", "Location", "based in", "currently living in").
- Do NOT extract city from Work Experience. A job that lists Karachi, UAE, USA, or any other city is the employer's location, not proof the candidate lives there.
- If the contact/location section does not name a city, return exactly: {NO_CITY}. Leave it blank rather than guessing from jobs, education, or nationality.
- The city string you return MUST appear verbatim (or as a clear substring) in that contact/location section of the resume text.
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.
@ -65,7 +73,7 @@ phone (its own key — extract this separately; copy EVERY digit):
Examples of CORRECT values (copy this completeness; these are format samples, not this candidate):
Example 1 local 11-digit PK mobile, full LinkedIn:
Resume: "Ali Khan | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience"
Resume: "Ali Khan | Karachi | 0321-5551234 | https://www.linkedin.com/in/ali-khan | Acme | BS CS | Engineer | Skills: Python, Django, PostgreSQL | 6 years of experience"
JSON:
{{
"current_employment": "Acme",
@ -73,6 +81,7 @@ JSON:
"current_title": "Engineer",
"linkedin_url": "https://www.linkedin.com/in/ali-khan",
"phone": "0321-5551234",
"city": "Karachi",
"skills": ["Python", "Django", "PostgreSQL"],
"years_experience": 6
}}
@ -101,6 +110,14 @@ Example 7 — dates only:
Resume: "Acme, Jan 2018 - Jan 2024, Engineer"
JSON years_experience must be 6, and skills must be [] because none are listed.
Example 8 work-experience cities are NOT residence:
Resume: "Ali Khan | 0321-5551234\\nExperience: Acme, Karachi, 2019-2021; Globex, UAE, 2022-2024; Contoso, USA, 2024-present"
JSON city must be exactly: {NO_CITY}. Do not return Karachi, UAE, USA, or any other job-site city.
Example 9 contact/location city is residence:
Resume: "Ali Khan | Location: Lahore | 0321-5551234\\nExperience: Acme, Karachi, Engineer"
JSON city must be "Lahore". Not "Karachi".
Respond with JSON only:
{{
"current_employment": "Company Name",
@ -108,9 +125,11 @@ Respond with JSON only:
"current_title": "Job Title",
"linkedin_url": "https://www.linkedin.com/in/slug",
"phone": "+92 300 1234567",
"city": "Lahore",
"skills": ["Skill One", "Skill Two"],
"years_experience": 5
}}
If the contact/location section has no city, city must be "{NO_CITY}" still return the rest of the JSON. Never omit the candidate because city is blank.
"""

View File

@ -18,6 +18,13 @@ load_dotenv()
router = APIRouter()
def _city_values(city: str | None):
if not city or not str(city).strip():
return None
parts=[p.strip() for p in str(city).split(",") if p.strip()]
return parts or None
class AppendRowsBody(BaseModel):
rows: list[list[str]]
@ -201,6 +208,8 @@ async def fetch_form_data(
# reason these exist, so `false` has to be a real filter and not "unset".
has_linkedin: bool | None = Query(None),
has_resume: bool | None = Query(None),
city: str | None = Query(None),
no_suggestions: bool | None = Query(None),
offset: int = Query(0,ge=0),
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
limit: int | None = Query(None,ge=1,le=500),
@ -213,6 +222,7 @@ async def fetch_form_data(
sheet=sheet,search=search,offset=offset,limit=limit,
processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city),no_suggestions=no_suggestions,
)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException:
@ -231,6 +241,7 @@ async def fetch_form_data_counts(
search: str | None = Query(None),
has_linkedin: bool | None = Query(None),
has_resume: bool | None = Query(None),
city: str | None = Query(None),
current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session),
):
@ -238,6 +249,7 @@ async def fetch_form_data_counts(
service=SheetFormData(session=session)
data=await service.get_counts(
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city),
)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:

View File

@ -206,6 +206,7 @@ class FormDataColumn(str, Enum):
EXPERIENCE_DETAILS = "experience_details"
AREA_OF_RESIDENCE = "area_of_residence"
RESIDING_CITY = "residing_city"
CITY = "city"
RESIDING_COUNTRY = "residing_country"
COMMUNICATION_SKILLS = "communication_skills"
PREFERRED_TIMINGS = "preferred_timings"
@ -223,6 +224,7 @@ class FormDataColumn(str, Enum):
# Same vocabulary as inbox_messages — Import / Shortlist / Reject / Duplicate.
PROCESSING_STATE = "processing_state"
IS_DUPLICATE = "is_duplicate"
REAPPLIED = "reapplied"
RAW_RECORD = "raw_record"
IMPORTED_AT = "imported_at"
CREATED_AT = "created_at"

View File

@ -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_
from sqlalchemy import Column, DateTime, Index, and_, case, delete, func, insert, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import Field, SQLModel, select
@ -85,6 +85,8 @@ class FormData(SQLModel, table=True):
area_of_residence: str | None = Field(default=None)
residing_city: str | None = Field(default=None)
residing_country: str | None = Field(default=None)
city: str | None = Field(default=None)
reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
communication_skills: int | None = Field(default=None)
preferred_timings: str | None = Field(default=None)
ho_availability: str | None = Field(default=None)
@ -109,10 +111,25 @@ class FormData(SQLModel, table=True):
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
@classmethod
def _no_suggested_jobs(cls):
"""True when suggested_job_post_ids is missing, not an array, or [].
jsonb_array_length() raises on scalar JSONB. CASE evaluates WHEN arms
in order, so length is only read after jsonb_typeof confirms an array.
"""
typeof = func.jsonb_typeof(cls.suggested_job_post_ids)
return case(
(cls.suggested_job_post_ids.is_(None), True),
(typeof != "array", True),
(func.jsonb_array_length(cls.suggested_job_post_ids) == 0, True),
else_=False,
)
@classmethod
def _filters(
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
has_linkedin=None, has_resume=None,
has_linkedin=None, has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
):
filters = []
if sheet:
@ -140,6 +157,21 @@ class FormData(SQLModel, table=True):
filters.append(
cls.resume_link.is_not(None) if has_resume else cls.resume_link.is_(None)
)
cities = [c.strip() for c in (city or []) if (c or "").strip()]
if cities:
city_col = func.lower(func.coalesce(cls.city, cls.residing_city))
filters.append(city_col.in_([c.lower() for c in cities]))
if no_suggestions is True:
filters.append(cls._no_suggested_jobs())
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":
filters.append(cls.assigned_job_post_id.is_(None))
filters.append(cls.job_post_id.is_(None))
elif inbox_filter == "rejected":
filters.append(cls.processing_state == "rejected")
elif inbox_filter == "duplicate":
filters.append(cls.is_duplicate == True) # noqa: E712
if search:
# Twelve unanchored ILIKEs over ~26k rows is a sequential scan of a few
# tens of ms — acceptable at this size; a pg_trgm GIN index is the
@ -160,6 +192,7 @@ class FormData(SQLModel, table=True):
cls.source_of_application.ilike(pattern),
cls.cnic.ilike(pattern),
cls.residing_city.ilike(pattern),
cls.city.ilike(pattern),
))
return filters
@ -313,20 +346,21 @@ class FormData(SQLModel, table=True):
async def fetch_form_data(
cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, offset=0, limit=None,
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
offset=0, limit=None,
):
statement = select(cls).order_by(cls.sheet, cls.row_number)
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
for clause in cls._filters(
sheet=sheet, search=search,
processing_state=processing_state, is_duplicate=is_duplicate,
has_linkedin=has_linkedin, has_resume=has_resume,
city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
):
statement = statement.where(clause)
if offset:
statement = statement.offset(offset)
if limit is not None:
statement = statement.limit(limit)
statement = statement.order_by(cls.row_number)
result = await session.execute(statement)
return result.scalars().all()
@ -368,17 +402,53 @@ class FormData(SQLModel, table=True):
})
return rows
@classmethod
async def job_post_ids_by_emails(cls, session: AsyncSession, emails):
"""(email, job_post_id) pairs from assigned or job_post_id. Unlinked skipped."""
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.candidate_email,cls.job_post_id,cls.assigned_job_post_id)
.where(func.lower(cls.candidate_email).in_(lowers))
)
rows=[]
for email,job_id,assigned_id in result.all():
key=(email or "").strip().lower()
if assigned_id is not None:
rows.append((key,str(assigned_id)))
if job_id is not None and job_id!=assigned_id:
rows.append((key,str(job_id)))
return rows
@classmethod
async def set_reapplied_by_emails(cls, session: AsyncSession, mapping):
if not mapping:
return 0
updated=0
for email, ids in mapping.items():
key=(email or "").strip().lower()
if not key:
continue
result=await session.execute(
update(cls).where(func.lower(cls.candidate_email)==key).values(reapplied=list(ids or []))
)
updated+=result.rowcount or 0
await session.commit()
return updated
@classmethod
async def count_form_data(
cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None,
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
):
statement = select(func.count()).select_from(cls)
for clause in cls._filters(
sheet=sheet, search=search,
processing_state=processing_state, is_duplicate=is_duplicate,
has_linkedin=has_linkedin, has_resume=has_resume,
city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
):
statement = statement.where(clause)
result = await session.execute(statement)
@ -387,7 +457,7 @@ class FormData(SQLModel, table=True):
@classmethod
async def count_processing(
cls, session: AsyncSession, *, sheet=None, search=None,
has_linkedin=None, has_resume=None,
has_linkedin=None, has_resume=None, city=None,
):
"""Tab badge counts for the Sheet Forms channel.
@ -408,10 +478,11 @@ class FormData(SQLModel, table=True):
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._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
).select_from(cls)
for clause in cls._filters(
sheet=sheet, search=search,
has_linkedin=has_linkedin, has_resume=has_resume,
has_linkedin=has_linkedin, has_resume=has_resume, city=city,
):
statement = statement.where(clause)
row = (await session.execute(statement)).one()
@ -422,6 +493,7 @@ class FormData(SQLModel, table=True):
"processed": int(row.processed or 0),
"rejected": int(row.rejected or 0),
"duplicates": int(row.duplicates or 0),
"on_hold": int(row.on_hold or 0),
}
@classmethod
@ -431,6 +503,15 @@ class FormData(SQLModel, table=True):
)
return list(result.scalars().all())
@classmethod
async def distinct_cities(cls, session: AsyncSession):
"""Non-blank city values on this table. Distinct only within form_data."""
col = func.coalesce(cls.city, cls.residing_city)
result = await session.execute(
select(col).where(col.is_not(None), col != "").distinct()
)
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
@classmethod
async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True):
count_result = await session.execute(
@ -548,6 +629,7 @@ class FormData(SQLModel, table=True):
"profile_link": cls._cell(data, "LinkedIn Profile Link"),
"residing_country": cls._cell(data, "Residing Country"),
"residing_city": cls._cell(data, "Residing City"),
"city": cls._cell(data, "Residing City"),
"ho_availability": cls._cell(data, "Are you willing to relocate?"),
"degree": cls._cell(data, "Educational Degree"),
"university": cls._cell(data, "University"),

View File

@ -252,6 +252,10 @@ class SheetImport(SheetRead):
]
mapped=await FormData.stamp_suggested_job_posts(session,mapped)
result=await FormData.replace_sheet(session,tab,mapped)
from inbox.views import Reapplied
await Reapplied(session=session).sync_for_emails(
[r.get("candidate_email") for r in mapped]
)
return serialize_import({
"tab":tab,
"rows_read":len(indexed),
@ -432,17 +436,20 @@ class SheetFormData(Sheet):
async def get_form_data(
self,sheet=None,search=None,offset=0,limit=None,
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
city=None,no_suggestions=None,
):
session=self._require_session()
rows=await FormData.fetch_form_data(
session,sheet=sheet,search=search,offset=offset,limit=limit,
processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
no_suggestions=no_suggestions,
)
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,
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
no_suggestions=no_suggestions,
)
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
from job.candidate.views import CandidateView
@ -473,6 +480,8 @@ class SheetFormData(Sheet):
updated=await FormData.set_job_post(session,record_id,job_post_id)
if not updated:
raise HTTPException(status_code=404,detail="Form data not found")
from inbox.views import Reapplied
await Reapplied(session=session).sync_for_email(updated.candidate_email)
if job_post_id is not None:
await self._promote_to_application(updated)
from g_sheet.scoring import enqueue_form_score
@ -567,6 +576,8 @@ class SheetFormData(Sheet):
"full_text":"",
"linkedin_url":linkedin_url,
})
from inbox.views import Reapplied
await Reapplied(session=session).sync_for_email(email)
await FormData.link_manual_upload(session,form_row.id,row.id)
try:
await HistoryRecorder(session).record(
@ -589,10 +600,10 @@ 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):
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None):
return await FormData.count_processing(
self._require_session(),sheet=sheet,search=search,
has_linkedin=has_linkedin,has_resume=has_resume,
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
)
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):

View File

@ -19,6 +19,20 @@ router = APIRouter()
_optional_bearer=HTTPBearer(auto_error=False)
def _city_values(city: str | None):
if not city or not str(city).strip():
return None
parts=[p.strip() for p in str(city).split(",") if p.strip()]
return parts or None
def _apps_payload(items,total,cities=None):
body={"data":items,"total":total,"status_code":200}
if cities is not None:
body["cities"]=cities
return JSONResponse(content=body)
def _cron_inbox_sync_token_ok(provided: str) -> bool:
expected=(os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip()
token=(provided or "").strip()
@ -81,6 +95,7 @@ class ReadAllBody(BaseModel):
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
@ -286,6 +301,7 @@ async def mark_all_inbox_read(
application_status=payload.application_status,
assigned=payload.assigned,
is_duplicate=payload.is_duplicate,
no_suggestions=payload.no_suggestions,
processing_state=payload.processing_state,
)
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
@ -321,6 +337,8 @@ async def get_all_applications(
no_suggestions: bool | None = Query(default=None),
processing_state: str | None = Query(default=None),
search: str | None = Query(None),
city: 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),
skip: int = Query(0, ge=0),
@ -329,22 +347,24 @@ async def get_all_applications(
):
try:
service=Email(session=session)
city_values=_city_values(city)
cities=await service.list_cities() 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)
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)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
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)
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)
return _apps_payload(items,total,cities)
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)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
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)
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)
return _apps_payload(items,total,cities)
if record_id:
item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200})
return _apps_payload(item,1,cities)
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
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)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values)
return _apps_payload(items,total,cities)
except HTTPException:
raise
except Exception as e:

View File

@ -681,6 +681,7 @@ class Inbox_Messages(SQLModel, table=True):
is_duplicate: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
source_channel_id: int | None = Field(default=None, foreign_key="source_channels.id")
processing_state: str = Field(default="unread", sa_column_kwargs={"server_default": "unread"})
city: str | None = Field(default=None)
# Ingestion time — list screens order by this (newest first). server_default
# backfills existing rows on the ALTER so NOT NULL is safe on a populated table.
created_at: datetime = Field(
@ -724,6 +725,7 @@ class Inbox_Messages(SQLModel, table=True):
current_employment=None,
current_title=None,
linkedin_url=None,
city=None,
suggested_job_post_ids=None,
summary="",
reasoning="",
@ -737,6 +739,7 @@ class Inbox_Messages(SQLModel, table=True):
if resume_text is not None:
row.resume_text = resume_text
url = (linkedin_url or "").strip() or None
user_id = None
if url:
row.linkedin_slug = slug_from_url(url) or NO_SLUG
user_id = await cls.get_linked_user_id(session, row.id)
@ -748,6 +751,15 @@ class Inbox_Messages(SQLModel, table=True):
# Agent ran and found no profile — mark scanned so talent backfill
# does not regex-scan this CV again.
row.linkedin_slug = NO_SLUG
city_value = (city or "").strip() or None
if city_value:
row.city = city_value
if user_id is None:
user_id = await cls.get_linked_user_id(session, row.id)
if user_id:
await Users.set_city_if_empty(
session, user_id=user_id, city=city_value,
)
if candidate_phone_number is not None:
row.candidate_phone_number = candidate_phone_number
if candidate_education is not None:
@ -924,6 +936,37 @@ class Inbox_Messages(SQLModel, table=True):
await session.execute(select(Inbox.user_id).where(Inbox.message_id==mid))
).scalar_one_or_none()
@classmethod
async def assigned_job_post_ids_by_emails(cls,session:AsyncSession,emails):
"""(email, assigned_job_post_id) for senders or linked users. Unlinked skipped."""
from users.models import Users
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.message_from,cls.assigned_job_post_id,Users.email)
.select_from(cls)
.outerjoin(Inbox,Inbox.message_id==cls.id)
.outerjoin(Users,Inbox.user_id==Users.id)
.where(cls.assigned_job_post_id.is_not(None))
.where(or_(
func.lower(cls.message_from).in_(lowers),
func.lower(Users.email).in_(lowers),
))
)
rows=[]
for sender,job_id,user_email in result.all():
if job_id is None:
continue
jid=str(job_id)
sender_key=(sender or "").strip().lower()
user_key=(user_email or "").strip().lower()
if sender_key in lowers:
rows.append((sender_key,jid))
if user_key in lowers and user_key!=sender_key:
rows.append((user_key,jid))
return rows
@classmethod
async def set_file_paths(cls,session:AsyncSession,record_id,file_paths,file_names=None):
row=await cls.get_inbox_message_by_id(session,record_id)
@ -963,6 +1006,22 @@ class Inbox_Messages(SQLModel, table=True):
cls.message_body.ilike(pattern),
)
@classmethod
def _no_suggested_jobs(cls):
"""True when suggested_job_post_ids is missing, not an array, or [].
jsonb_array_length() raises on scalar JSONB (a lone uuid string, an
object, json null). CASE evaluates WHEN arms in order, so length is
only read after jsonb_typeof confirms an array.
"""
typeof = func.jsonb_typeof(cls.suggested_job_post_ids)
return case(
(cls.suggested_job_post_ids.is_(None), True),
(typeof != "array", True),
(func.jsonb_array_length(cls.suggested_job_post_ids) == 0, True),
else_=False,
)
@classmethod
def _apply_filters(
cls, statement, search: str | None=None, isread: bool=True,
@ -971,6 +1030,8 @@ class Inbox_Messages(SQLModel, table=True):
is_duplicate: bool | None=None,
no_suggestions: bool | None=None,
processing_state: str | None=None,
city=None,
inbox_filter: str | None=None,
):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -994,14 +1055,20 @@ class Inbox_Messages(SQLModel, table=True):
elif is_duplicate is False:
statement = statement.where(cls.is_duplicate==False) # noqa: E712
if no_suggestions is True:
statement = statement.where(cls.assigned_job_post_id.is_(None)).where(
or_(
cls.suggested_job_post_ids.is_(None),
func.jsonb_array_length(cls.suggested_job_post_ids) == 0,
)
)
statement = statement.where(cls._no_suggested_jobs())
if processing_state:
statement = statement.where(cls.processing_state == processing_state)
cities = [c.strip() for c in (city or []) if (c or "").strip()]
if cities:
statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities]))
if inbox_filter == "matched":
statement = statement.where(cls.match_status == "matched")
elif inbox_filter == "unassigned":
statement = statement.where(cls.assigned_job_post_id.is_(None))
elif inbox_filter == "rejected":
statement = statement.where(cls.processing_state == "rejected")
elif inbox_filter == "duplicate":
statement = statement.where(cls.is_duplicate == True) # noqa: E712
# Inbox / Job Matching only list applications that arrived with a file.
# Graph hasAttachments lands on this column; body-only mail stays out.
statement = statement.where(cls.attachment == True) # noqa: E712
@ -1009,13 +1076,12 @@ 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, 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, light: bool=False
):
statement = cls._apply_filters(
select(cls).order_by(cls.message_received_time.desc(),cls.id.desc()),
select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state,
no_suggestions, processing_state, city,
)
if skip:
statement = statement.offset(skip)
@ -1096,15 +1162,26 @@ 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):
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):
statement = cls._apply_filters(
select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state,
no_suggestions, processing_state, city,
)
result = await session.execute(statement)
return result.scalar_one()
@classmethod
async def distinct_cities(cls, session: AsyncSession):
"""Non-blank city values on inbox applications. Distinct only within this table."""
result = await session.execute(
select(cls.city)
.where(cls.city.is_not(None), cls.city != "")
.where(cls.attachment == True) # noqa: E712
.distinct()
)
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
@classmethod
async def apply_read_status(cls, session: AsyncSession, changes) -> int:
"""[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched.
@ -1205,6 +1282,7 @@ class Inbox_Messages(SQLModel, table=True):
assigned: bool | None=None,
is_duplicate: bool | None=None,
processing_state: str | None=None,
no_suggestions: bool | None=None,
) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED.
@ -1213,7 +1291,7 @@ class Inbox_Messages(SQLModel, table=True):
was already read. It also keeps read_overridden_at off rows nobody decided
anything about, so the Outlook sweep keeps its reach over untouched mail.
"""
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,processing_state=processing_state)
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,no_suggestions,processing_state)
statement=statement.where(cls.message_read!=bool(read))
result=await session.execute(
statement.values(message_read=bool(read),read_overridden_at=_now())
@ -1230,6 +1308,7 @@ class Inbox_Messages(SQLModel, table=True):
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._no_suggested_jobs(), 1), else_=0)), 0).label("on_hold"),
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
@ -1241,6 +1320,7 @@ class Inbox_Messages(SQLModel, table=True):
"processed": int(row.processed or 0),
"rejected": int(row.rejected or 0),
"duplicates": int(row.duplicates or 0),
"on_hold": int(row.on_hold or 0),
"assigned": int(row.assigned or 0),
"unassigned": int(row.unassigned or 0),
}

View File

@ -61,6 +61,7 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
"body": message.message_body,
"when": message.message_received_time,
"received": message.message_received_time,
"created_at": message.created_at.isoformat() if message.created_at else None,
"unread": not message.message_read,
"attachment": message.attachment,
"attachment_name": attachment_name,
@ -120,6 +121,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light:
"position": message.message_subject,
"source": message.message_to,
"received": message.message_received_time,
"created_at": message.created_at.isoformat() if message.created_at else None,
"unread": not message.message_read,
"processing": processing,
"application_status": message.application_status,
@ -141,6 +143,7 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light:
"experience": message.experience or "",
"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,
"duplicate": message.is_duplicate,
"processing_state": message.processing_state,

View File

@ -146,6 +146,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
current_title=fields["current_title"]
linkedin_url=fields["linkedin_url"]
phone=fields["phone"]
city=fields.get("city") or None
async with session_scope() as session:
await Inbox_Messages.set_match_result(
@ -158,6 +159,7 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
current_title=current_title,
candidate_education=education,
linkedin_url=linkedin_url,
city=city,
suggested_job_post_ids=result.get("suggested_job_post_ids") or [],
summary=result.get("summary") or "",
reasoning=result.get("reasoning") or "",

View File

@ -212,6 +212,7 @@ class Email:
row,new_user_email=await Inbox_Messages.insert_email(
session=self.session,email_data=data,file_path=None,
)
await Reapplied(session=self.session).sync_for_email(row.message_from)
if pdfs:
row=await attach_email_pdfs_to_s3(
self.session,row,pdfs,created_new=(already is None),
@ -273,18 +274,57 @@ class Email:
item["assigned_job_post"]=None
return await cv.attach_application_history(item)
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):
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):
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,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,light=True)
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,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,light=True)
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,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,light=True)
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)
from job.candidate.views import CandidateView
return await CandidateView(session=self.session).attach_application_history(items)
async def _attach_job_posts(self,items):
"""List payload needs assigned + suggested job objects — export reads titles.
Detail hydrates one row; the queue used to ship ids only. Assigned job and
Suggested jobs in the Inbox .xlsx were then blank for email applicants.
"""
ids=[]
for item in items:
aid=item.get("assigned_job_post_id")
if aid:
ids.append(aid)
for sid in item.get("suggested_job_post_ids") or []:
if sid:
ids.append(sid)
by_id={}
if ids:
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
for post in await JobPosts.get_by_ids(self.session,ids,active_only=False):
payload=serialize_job_post(post)
if post.is_deleted or not post.is_active:
payload={**payload,"unavailable":True}
by_id[str(post.id)]=payload
for item in items:
aid=item.get("assigned_job_post_id")
item["assigned_job_post"]=by_id.get(str(aid)) if aid else None
suggested=[]
for sid in item.get("suggested_job_post_ids") or []:
if not sid:
continue
payload=by_id.get(str(sid))
if payload is None:
suggested.append({"id":str(sid),"unavailable":True})
else:
suggested.append(dict(payload))
item["suggested_job_posts"]=suggested
return items
async def get_application_by_id(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
@ -438,13 +478,20 @@ 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):
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):
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)
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)
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)
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)
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)
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)
async def list_cities(self):
"""Distinct cities from inbox_messages and form_data, merged in Python."""
from g_sheet.models import FormData
inbox=await Inbox_Messages.distinct_cities(self.session)
forms=await FormData.distinct_cities(self.session)
return Reapplied(session=self.session).merge_cities(inbox,forms)
async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
@ -458,6 +505,7 @@ class Email:
updated=await Inbox_Messages.set_assigned_job_post(self.session,record_id,job_post_id)
if not updated:
raise HTTPException(status_code=404,detail="Message not found")
await Reapplied(session=self.session).sync_for_email(updated.message_from)
if job_post_id is not None:
# Assignment pairs this CV with a JD we already have — queue the ATS
# score in the background so the recruiter is not held on an OpenAI
@ -502,7 +550,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,processing_state=None):
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=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
@ -512,7 +560,7 @@ class Email:
updated=await Inbox_Messages.set_read_scope(
self.session,read,search=search,isread=isread,
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
processing_state=processing_state,
no_suggestions=no_suggestions,processing_state=processing_state,
)
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)
@ -618,6 +666,7 @@ class Email:
message,new_user_email=await Inbox_Messages.insert_email(
session=self.session,email_data=data,file_path=None,
)
await Reapplied(session=self.session).sync_for_email(message.message_from)
if pdfs:
message=await attach_email_pdfs_to_s3(
self.session,message,pdfs,created_new=(already is None),
@ -708,3 +757,78 @@ class Email:
except Exception as exc:
logger.warning("notification insert skipped: %s",exc)
return {"accepted":True,"to":to_email,"subject":subject}
class Reapplied:
def __init__(self,session:AsyncSession):
self.session=session
self.collected={}
self.seen={}
def _norm_email(self,email):
return (email or "").strip().lower()
def _as_job_id(self,value):
if value in (None,""):
return None
text=str(value).strip()
return text or None
def _add(self,email,job_id):
uid=self._as_job_id(job_id)
if not uid or email not in self.seen or uid in self.seen[email]:
return
self.seen[email].add(uid)
self.collected[email].append(uid)
def merge_cities(self,*groups):
"""Case-insensitive unique cities, first spelling wins, sorted."""
seen=set()
out=[]
for group in groups:
for raw in group or []:
text=(raw or "").strip()
if not text:
continue
key=text.lower()
if key in seen:
continue
seen.add(key)
out.append(text)
out.sort(key=str.lower)
return out
async def sync_for_email(self,email):
stamped=await self.sync_for_emails([email])
return stamped.get(self._norm_email(email),[])
async def sync_for_emails(self,emails):
"""Collect linked job_post_ids for these emails and stamp reapplied on all 3 tables.
Records with no job_post_id are ignored while collecting. Empty collections
leave reapplied untouched.
"""
from g_sheet.models import FormData
from job.candidate.models import Manual_UPLOAD_CANDIDATE
from users.models import Users
lowers=sorted({self._norm_email(e) for e in (emails or []) if self._norm_email(e)})
if not lowers:
return {}
self.collected={email:[] for email in lowers}
self.seen={email:set() for email in lowers}
for email,job_id in await Manual_UPLOAD_CANDIDATE.job_post_ids_by_emails(self.session,lowers):
self._add(email,job_id)
for email,job_id in await FormData.job_post_ids_by_emails(self.session,lowers):
self._add(email,job_id)
for email,job_id in await Inbox_Messages.assigned_job_post_ids_by_emails(self.session,lowers):
self._add(email,job_id)
stamped={email:ids for email,ids in self.collected.items() if ids}
if not stamped:
return {}
await Users.set_reapplied_by_emails(self.session,stamped)
await Manual_UPLOAD_CANDIDATE.set_reapplied_by_emails(self.session,stamped)
await FormData.set_reapplied_by_emails(self.session,stamped)
return stamped

View File

@ -355,6 +355,8 @@ async def cv_bank_upload(
bank_reason="speculative",
retention_months=CV_BANK_RETENTION_MONTHS,
)
from inbox.views import Reapplied
await Reapplied(session=session).sync_for_email(detected or "")
try:
uploaded=S3().upload_for_record(
content,

View File

@ -3,7 +3,7 @@ from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, List, Optional
from fastapi import HTTPException
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_
from sqlalchemy import JSON, DateTime, Index, UniqueConstraint, and_, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -59,6 +59,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
# the numeric years the bank filters and sorts by, so they stay separate.
skills: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
years_experience: int | None = Field(default=None)
city: str | None = Field(default=None)
reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
education: str = Field(default="", sa_column_kwargs={"server_default": ""})
# Why the CV is held (speculative / referral) and when retention expires.
bank_reason: str = Field(default="", sa_column_kwargs={"server_default": ""})
@ -321,6 +323,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
referral_by=(fields.get("referral_by") or "").strip(),
file_name=(fields.get("file_name") or "").strip(),
file_path=(fields.get("file_path") or "").strip(),
city=(fields.get("city") or "").strip() or None,
)
session.add(row)
await session.commit()
@ -433,6 +436,40 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
})
return rows
@classmethod
async def job_post_ids_by_emails(cls, session: AsyncSession, emails):
"""(email, job_post_id) pairs that actually have a job. Unlinked rows skipped."""
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.candidate_email,cls.job_post_id)
.where(func.lower(cls.candidate_email).in_(lowers))
.where(cls.job_post_id.is_not(None))
)
rows=[]
for email,job_id in result.all():
if job_id is None:
continue
rows.append(((email or "").strip().lower(),str(job_id)))
return rows
@classmethod
async def set_reapplied_by_emails(cls, session: AsyncSession, mapping):
if not mapping:
return 0
updated=0
for email, ids in mapping.items():
key=(email or "").strip().lower()
if not key:
continue
result=await session.execute(
update(cls).where(func.lower(cls.candidate_email)==key).values(reapplied=list(ids or []))
)
updated+=result.rowcount or 0
await session.commit()
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)."""
@ -603,6 +640,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
status="BANKED",
file_name=(file_name or "").strip(),
file_path="",
city=(extracted.get("city") or "").strip() or None,
)
session.add(row)
await session.flush()
@ -613,6 +651,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
file_path=None,
data=pdf_bytes,
))
if user and row.city:
await Users.set_city_if_empty(session, user_id=user.id, city=row.city)
await session.commit()
await session.refresh(row)
return row
@ -681,6 +721,8 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
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
if not (row.city or "").strip() and profile.get("city"):
row.city = (profile.get("city") or "").strip() or None
row.updated_at = _now()
session.add(row)
await session.commit()

View File

@ -274,7 +274,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
"""
blank={
"linkedin_url":None,"current_company":"","current_position":"",
"education":"","candidate_phone":"","skills":[],"years_experience":None,
"education":"","candidate_phone":"","city":None,"skills":[],"years_experience":None,
}
text=(resume_text or "").strip()
if not text:
@ -303,6 +303,7 @@ async def extract_bank_profile_from_cv(resume_text) -> dict:
"current_position":unless_sentinel("current_title",CURRENT_TITLE),
"education":unless_sentinel("education",EDUCATION),
"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,
}
@ -479,6 +480,8 @@ class FileRead:
row,new_user_email=await Inbox_Messages.insert_email(
self.session,email_data,file_path=None,
)
from inbox.views import Reapplied
await Reapplied(session=self.session).sync_for_email(email)
try:
row=await attach_email_pdfs_to_s3(self.session,row,pdfs,created_new=True)
except Exception as e:
@ -994,6 +997,8 @@ class CandidateView:
"created_by":current_user,
}
row=await Manual_UPLOAD_CANDIDATE.create_manual_upload_candidate(session=self.session,fields=data)
from inbox.views import Reapplied
await Reapplied(session=self.session).sync_for_email(email)
if file_bytes is not None:
try:

View File

@ -0,0 +1,40 @@
-- 031_candidate_city_reapplied.sql
-- City of residence (employment-agent extraction) on the four candidate
-- surfaces, plus a denormalised reapplied JSONB list of prior job_post_ids
-- on users / manual_upload_candidate / form_data.
--
-- form_data already has residing_city from the sheet; city is a separate
-- column so inbox / users / bank share one name. Existing sheet values are
-- copied across. Applied at startup by alembic_setup.run_manual_sql().
ALTER TABLE app.users
ADD COLUMN IF NOT EXISTS city TEXT,
ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE app.manual_upload_candidate
ADD COLUMN IF NOT EXISTS city TEXT,
ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE app.form_data
ADD COLUMN IF NOT EXISTS city TEXT,
ADD COLUMN IF NOT EXISTS reapplied JSONB NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE app.inbox_messages
ADD COLUMN IF NOT EXISTS city TEXT;
UPDATE app.form_data
SET city = residing_city
WHERE city IS NULL
AND COALESCE(TRIM(residing_city), '') <> '';
CREATE INDEX IF NOT EXISTS ix_users_city
ON app.users (city);
CREATE INDEX IF NOT EXISTS ix_manual_upload_candidate_city
ON app.manual_upload_candidate (city);
CREATE INDEX IF NOT EXISTS ix_form_data_city
ON app.form_data (city);
CREATE INDEX IF NOT EXISTS ix_inbox_messages_city
ON app.inbox_messages (city);

View File

@ -3,7 +3,8 @@ import uuid
from datetime import datetime, timezone
from typing import TYPE_CHECKING,List,Optional
from sqlalchemy import DateTime, func, or_
from sqlalchemy import DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field, Relationship, SQLModel, select
@ -60,6 +61,10 @@ class Users(SQLModel, table=True):
# Public profile URL extracted from a CV at ingest. NULL until a CV
# mentions LinkedIn; never overwrite a stored value with empty.
linkedin_url: str | None = Field(default=None)
city: str | None = Field(default=None)
# Prior job_post_ids for this email across candidate tables. [] until a
# later application finds an already-linked job.
reapplied: list[str] = Field(default_factory=list, sa_type=JSONB, sa_column_kwargs={"server_default": "[]"})
created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True))
is_active: bool = Field(default=False)
@ -251,6 +256,30 @@ class Users(SQLModel, table=True):
session.add(user)
return True
@classmethod
async def set_city_if_empty(cls, session: AsyncSession, *, user_id=None, email=None, city=None) -> bool:
"""Write city only when the user has none yet. Caller commits."""
value = (city or "").strip() or None
if not value:
return False
statement = select(cls)
if user_id is not None:
uid = cls._as_uuid(user_id)
if uid is None:
return False
statement = statement.where(cls.id == uid)
elif email:
statement = statement.where(func.lower(cls.email) == str(email).strip().lower())
else:
return False
user = (await session.execute(statement)).scalars().first()
if user is None or (user.city or "").strip():
return False
user.city = value
user.updated_at = _now()
session.add(user)
return True
@classmethod
async def get_pending_approvals(cls, session: AsyncSession):
"""Email-confirmed staff accounts waiting on an admin to set is_approved."""
@ -308,4 +337,22 @@ class Users(SQLModel, table=True):
return user
@classmethod
async def set_reapplied_by_emails(cls, session: AsyncSession, mapping):
"""Write reapplied job_post_id lists keyed by lowercased email."""
if not mapping:
return 0
updated=0
for email, ids in mapping.items():
key=(email or "").strip().lower()
if not key:
continue
result=await session.execute(
update(cls).where(func.lower(cls.email)==key).values(reapplied=list(ids or []))
)
updated+=result.rowcount or 0
await session.commit()
return updated
import job.candidate.models as _candidate_models # noqa: E402, F401

View File

@ -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 } = {}) {
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, cityList } = {}) {
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
@ -28,10 +28,11 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
// Same for `is_duplicate`: omit unless the Duplicates tab.
// `no_suggestions`: Job Matching "No suggestions" tab — unassigned + empty
// suggested_job_post_ids. Omit unless that tab.
// `no_suggestions`: Inbox On-Hold tab — no suggested job post linked.
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
// processed, not application_status PROCESS).
// `city`: optional comma-separated list. `city_list`: include merged distinct
// cities from inbox_messages and form_data on the same response.
params: {
search,
top,
@ -43,6 +44,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
is_duplicate: isDuplicate,
no_suggestions: noSuggestions,
processing_state: processingState,
city,
city_list: cityList,
},
})
}
@ -125,7 +128,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, processingState } = {}) {
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) {
return request('/inbox/read-all', {
method: 'PATCH',
body: {
@ -135,6 +138,7 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
application_status: applicationStatus,
assigned,
is_duplicate: isDuplicate,
no_suggestions: noSuggestions,
processing_state: processingState,
},
})

View File

@ -24,12 +24,12 @@ export function listFormDataSheets() {
*/
export function listFormData({
sheet, search, offset = 0, limit, processing_state, is_duplicate,
hasLinkedin, hasResume,
hasLinkedin, hasResume, city, no_suggestions,
} = {}) {
return request('/sheet/form-data/fetch', {
params: {
sheet, search, offset, limit, processing_state, is_duplicate,
has_linkedin: hasLinkedin, has_resume: hasResume,
has_linkedin: hasLinkedin, has_resume: hasResume, city, no_suggestions,
},
})
}
@ -47,9 +47,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 } = {}) {
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city } = {}) {
return request('/sheet/form-data/counts', {
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume },
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city },
})
}

View File

@ -24,30 +24,15 @@ const BRAND = {
const thin = { style: 'thin', color: { argb: BRAND.border } }
/**
* Build and download a styled .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox"
* @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026"
* @param {Array<{header: string, key: string, width?: number}>} opts.columns
* @param {Array<object>} opts.rows keyed by columns[].key; null/undefined print blank
*/
export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) {
const ExcelJS = (await import('exceljs')).default
const wb = new ExcelJS.Workbook()
wb.creator = 'TalentFlow ATS'
wb.created = new Date()
const ws = wb.addWorksheet(title.slice(0, 31) || 'Export', {
views: [{ state: 'frozen', ySplit: 3 }],
})
function sanitizeSheetName(name) {
const cleaned = String(name || 'Export').replace(/[:\\/?*[\]]/g, ' ').trim()
return cleaned.slice(0, 31) || 'Export'
}
function paintSheet(ws, { title, subtitle, columns, rows }) {
ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 }))
const span = columns.length
const span = Math.max(1, columns.length)
// Row 1 — brand title band
const titleRow = ws.addRow([title])
ws.mergeCells(1, 1, 1, span)
titleRow.height = 30
@ -56,7 +41,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.green } }
titleCell.alignment = { vertical: 'middle', indent: 1 }
// Row 2 — meta line
const metaRow = ws.addRow([subtitle])
ws.mergeCells(2, 1, 2, span)
metaRow.height = 20
@ -65,7 +49,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
metaCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.mint } }
metaCell.alignment = { vertical: 'middle', indent: 1 }
// Row 3 — column headers
const headRow = ws.addRow(columns.map((c) => c.header))
headRow.height = 22
headRow.eachCell((cell) => {
@ -75,7 +58,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
cell.border = { bottom: { style: 'medium', color: { argb: BRAND.lime } } }
})
// Data — zebra rows with hairline borders
for (const [i, r] of rows.entries()) {
const row = ws.addRow(columns.map((c) => r[c.key] ?? ''))
row.eachCell({ includeEmpty: true }, (cell) => {
@ -87,7 +69,9 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
}
ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } }
}
async function downloadWorkbook(wb, filename) {
const buf = await wb.xlsx.writeBuffer()
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const a = document.createElement('a')
@ -98,3 +82,54 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row
a.remove()
URL.revokeObjectURL(a.href)
}
/**
* Build and download a styled multi-sheet .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {Array<{name?: string, title: string, subtitle: string, columns: Array<{header: string, key: string, width?: number}>, rows: Array<object>}>} opts.sheets
*/
export async function exportStyledWorkbook({ filename, sheets }) {
const ExcelJS = (await import('exceljs')).default
const wb = new ExcelJS.Workbook()
wb.creator = 'TalentFlow ATS'
wb.created = new Date()
const used = new Set()
for (const sheet of sheets) {
if (!sheet?.columns?.length) continue
let name = sanitizeSheetName(sheet.name || sheet.title)
if (used.has(name)) {
const base = name.slice(0, 28)
let n = 2
while (used.has(`${base} ${n}`)) n += 1
name = `${base} ${n}`
}
used.add(name)
const ws = wb.addWorksheet(name, {
views: [{ state: 'frozen', ySplit: 3 }],
})
paintSheet(ws, sheet)
}
if (!used.size) throw new Error('Nothing to export')
await downloadWorkbook(wb, filename)
}
/**
* Build and download a styled .xlsx.
*
* @param {object} opts
* @param {string} opts.filename without extension
* @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox"
* @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026"
* @param {Array<{header: string, key: string, width?: number}>} opts.columns
* @param {Array<object>} opts.rows keyed by columns[].key; null/undefined print blank
*/
export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) {
return exportStyledWorkbook({
filename,
sheets: [{ name: title, title, subtitle, columns, rows }],
})
}

View File

@ -37,6 +37,7 @@ export const qk = {
formCounts: (p = {}) => ['mailbox', 'form-counts', p],
applicationTotal: () => ['mailbox', 'application-total'],
formTotal: (p = {}) => ['mailbox', 'form-total', p],
cities: () => ['mailbox', 'cities'],
},
assessments: {
all: () => ['assessments'],

View File

@ -31,11 +31,14 @@ import Charts from '../lib/charts'
import ChartCard, { widgetError } from '../ui/ChartCard'
import PageHeader from '../ui/PageHeader'
import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { qk } from '../lib/queryKeys'
import { exportStyledWorkbook } from '../lib/exportXlsx'
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
import { fmtWeekdayDate, fmtShort } from '../lib/format'
import { fmtWeekdayDate, fmtShort, fmtDate } from '../lib/format'
import { friendlyAuthError } from '../lib/errors'
import { money } from '../data/seed'
import * as activityApi from '../api/activity'
import * as analyticsApi from '../api/analytics'
@ -102,6 +105,67 @@ function fmtWhen(iso) {
return fmtShort(iso) || '—'
}
const PERIOD_SHEET_NAMES = {
week: 'Weekly',
month: 'Monthly',
quarter: 'Quarterly',
year: 'Yearly',
}
const PERIOD_METRICS = [
{ label: 'Open Jobs', key: 'open_jobs' },
{ label: 'Applications', key: 'total_candidates' },
{ label: 'Hires', key: 'hires' },
{ label: 'Offers Sent', key: 'offers_sent' },
{ label: 'Offers Accepted', key: 'offers_accepted' },
{ label: 'Cost per Hire', key: 'cost_per_hire', round: true },
]
function kpiValue(kpis, key, { round = false } = {}) {
if (!kpis || kpis[key] == null || kpis[key] === '') return ''
const n = Number(kpis[key])
if (!Number.isFinite(n)) return kpis[key]
return round ? Math.round(n) : n
}
async function fetchRangeSnapshot(rangeKey, department) {
const span = rangeWindow(rangeKey)
const kpisRes = await analyticsApi.kpis({
...span,
department: department || undefined,
})
return {
key: rangeKey,
fromDate: span.fromDate,
toDate: span.toDate,
kpis: asObject(kpisRes?.data),
}
}
function buildPeriodSheets({ department, snapshots, exportedAt }) {
const deptLabel = department || 'All Departments'
const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10)
const columns = [
{ header: 'Metric', key: 'metric', width: 22 },
{ header: 'Value', key: 'value', width: 16 },
]
return snapshots.map((s) => {
const name = PERIOD_SHEET_NAMES[s.key] || s.key
const from = fmtDate(s.fromDate) || s.fromDate
const to = fmtDate(s.toDate) || s.toDate
return {
name,
title: name,
subtitle: `${from} ${to} · ${deptLabel} · exported ${stamp}`,
columns,
rows: PERIOD_METRICS.map((spec) => ({
metric: spec.label,
value: kpiValue(s.kpis, spec.key, { round: spec.round }),
})),
}
})
}
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
if (query.isPending) {
return (
@ -136,12 +200,14 @@ export default function Dashboard() {
function DashboardHome() {
const navigate = useNavigate()
const { toast } = useToast()
const { user } = useAuth()
const firstName = (user?.name || 'there').split(' ')[0]
const todayLabel = formatDashDate()
const [rangeKey, setRangeKey] = useState('month')
const [department, setDepartment] = useState('')
const [exporting, setExporting] = useState(false)
// rangeWindow returns fresh ISO strings on every call recompute only when
// the key changes, or every render would churn the query keys below.
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
@ -353,6 +419,30 @@ function DashboardHome() {
},
]
async function exportDashboard() {
if (exporting) return
setExporting(true)
try {
const snapshots = await Promise.all(
RANGES.map((r) => fetchRangeSnapshot(r.key, department)),
)
const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all'
await exportStyledWorkbook({
filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`,
sheets: buildPeriodSheets({
department,
snapshots,
exportedAt: new Date(),
}),
})
toast('Dashboard exported to Excel', 'success')
} catch (err) {
toast(friendlyAuthError(err, 'Could not export the dashboard'), 'error')
} finally {
setExporting(false)
}
}
return (
<div className="page">
<PageHeader
@ -377,9 +467,14 @@ function DashboardHome() {
<option value="">All Departments</option>
{departments.map((d) => <option key={d}>{d}</option>)}
</select>
<Link className="btn btn-secondary" to="/reports">
<Icon name="download" /> Export
</Link>
<button
type="button"
className="btn btn-secondary"
onClick={exportDashboard}
disabled={exporting}
>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
<Icon name="plus" /> Create Job
</Link>

View File

@ -37,9 +37,9 @@ import {
inboxSources, sourceMeta,
} from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates']
const TABS = ['All Applications', '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', 'Rejected', 'Duplicates']
const FORM_TABS = ['All Applications', 'Processed', 'On-Hold', 'Rejected', 'Duplicates']
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
const PAGE_SIZE_MAX = 500
@ -71,8 +71,8 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
/** Typing must not put a query key (and a skeleton) on screen per keystroke. */
const SEARCH_DEBOUNCE_MS = 300
/** Sheet Forms link filters. '' = any, 'yes' / 'no' are both real filters. */
const EMPTY_FORM_FILTERS = { hasLinkedin: '', hasResume: '' }
/** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */
const EMPTY_INBOX_FILTERS = { location: '', hasLinkedin: '', hasResume: '' }
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */
function agoLabel(ts) {
@ -117,18 +117,21 @@ const SHEET_SOURCE_META = { icon: 'layers', color: 'var(--c4)', channel: 'Sheet'
/**
* Server-side filters for each tab. Email Processed / Rejected follow
* processing_state (the same writes as Import / Shortlist / Reject). Sheet
* Forms use form_data.processing_state.
* processing_state (the same writes as Import / Shortlist / Reject). On-Hold
* is applications with no suggested job post. Sheet Forms use the same
* processing_state / no_suggestions columns on form_data.
*/
const TAB_FILTERS = {
Unread: { isread: false },
Processed: { processingState: 'processed' },
'On-Hold': { noSuggestions: true },
Rejected: { processingState: 'rejected' },
Duplicates: { isDuplicate: true },
}
const FORM_TAB_FILTERS = {
Processed: { processing_state: 'processed' },
'On-Hold': { no_suggestions: true },
Rejected: { processing_state: 'rejected' },
Duplicates: { is_duplicate: true },
}
@ -251,6 +254,36 @@ function asAtsScore(value) {
return Number.isFinite(n) ? n : null
}
/** Assigned job title for export — list rows may carry the post or only an id + jobPosts. */
function assignedJobTitle(row) {
const fromPost = row?.assignedPost?.title
if (fromPost) return fromPost
const id = row?.assignedId
if (!id) return ''
const fromCards = (row.jobPosts || []).find((p) => String(p.id) === String(id))
return fromCards?.title || ''
}
/** Suggestion titles, assigned job omitted so the two export columns do not repeat. */
function suggestedJobTitles(row) {
const assignedId = row?.assignedId ? String(row.assignedId) : ''
const posts = Array.isArray(row?.suggestedPosts) && row.suggestedPosts.length
? row.suggestedPosts
: (row?.jobPosts || [])
const titles = []
const seen = new Set()
for (const post of posts) {
if (assignedId && String(post?.id) === assignedId) continue
const title = String(post?.title || '').trim()
if (!title) continue
const key = title.toLowerCase()
if (seen.has(key)) continue
seen.add(key)
titles.push(title)
}
return titles.join('; ')
}
/**
* GET /sheet/form-data/fetch row the same list/detail shape the email channel
* uses for name / avatar / position / source / time, plus form-only profile fields.
@ -282,6 +315,7 @@ function mapFormRow(row) {
position: row.position_applied_for || '—',
...FORM_LIST_SOURCE,
received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)),
createdAt: parseGraphDate(row.created_at),
screenedBy: row.screened_by || '',
hrComments: row.hr_comments || '',
gender: row.gender || '',
@ -291,7 +325,8 @@ function mapFormRow(row) {
university: row.university || '',
universityOther: row.university_other || '',
graduationYear: row.entry_year || '',
residingCity: row.residing_city || '',
residingCity: row.city || row.residing_city || '',
city: row.city || row.residing_city || '',
residingCountry: row.residing_country || '',
maritalStatus: row.marital_status || '',
hoAvailability: row.ho_availability || '',
@ -430,6 +465,7 @@ async function fetchMessageDetail(recordId) {
position: row.subject || '(no subject)',
...sourceFrom(row.message_to),
received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time),
createdAt: parseGraphDate(row.created_at),
unread: Boolean(row.unread),
processing: row.unread ? 'Unread' : 'Read',
resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending',
@ -487,6 +523,7 @@ async function fetchApplications(params) {
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseGraphDate(row.received),
createdAt: parseGraphDate(row.created_at),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
processingState: row.processing_state || null,
@ -503,11 +540,15 @@ async function fetchApplications(params) {
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
city: row.city || '',
residingCity: row.city || '',
duplicate: Boolean(row.duplicate),
suggestedIds: Array.isArray(row.suggested_job_post_ids)
? row.suggested_job_post_ids.map(String)
: [],
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
@ -844,22 +885,28 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
}
/**
* Sheet Forms link filters, stacked for the queue column.
* Inbox list filters, stacked for the queue column.
*
* NOT the shared `.filter-panel` used by Candidates and CvBank: that lays out in
* a four-column grid whose breakpoints watch the VIEWPORT, while this column is
* `minmax(280px, 34%)`. On any wide screen it would try to fit four columns into
* about four hundred pixels. This stacks instead, like the Progress sidebar.
*
* Collapsed by default because two permanent selects eat scarce vertical space
* above the queue but the toggle carries the active count, because a collapsed
* panel silently hiding a filter is how a recruiter concludes the list is broken.
* Location is on every channel. LinkedIn / Resume stay Sheet Forms only those
* columns do not exist on email rows. Collapsed by default because extra selects
* eat scarce vertical space above the queue, but the toggle carries the active
* count so a collapsed panel cannot silently hide a filter.
*
* (`Facet` in Candidates.jsx and CvBank.jsx is the same idea in a wider column.
* Both files are mid-edit elsewhere, so this is deliberately a local copy rather
* than a refactor of theirs.)
*/
function FormLinkFilters({ filters, active, open, onToggle, onChange, onClear }) {
/** Junk location tokens — hide from the dropdown, not from the list query. */
const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i
function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) {
const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim()))
const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim())
return (
<div className="inbox-filters">
<button
@ -878,33 +925,53 @@ function FormLinkFilters({ filters, active, open, onToggle, onChange, onClear })
{open && (
<div className="inbox-filter-stack">
<div className="form-field">
<label htmlFor="inbox-f-linkedin">LinkedIn link</label>
<label htmlFor="inbox-f-location">Location</label>
<select
id="inbox-f-linkedin"
value={filters.hasLinkedin}
onChange={(e) => onChange('hasLinkedin', e.target.value)}
id="inbox-f-location"
value={filters.location}
onChange={(e) => onChange('location', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Has a LinkedIn link</option>
<option value="no">No LinkedIn link</option>
</select>
{/* The form field is free text and nothing validates it on the way
in, so this matches on the domain and cannot promise the link
actually resolves to a profile. Say so rather than imply more. */}
<span className="cell-sub">Matches the link text, not a verified profile.</span>
</div>
<div className="form-field">
<label htmlFor="inbox-f-resume">Resume link</label>
<select
id="inbox-f-resume"
value={filters.hasResume}
onChange={(e) => onChange('hasResume', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Has a resume link</option>
<option value="no">No resume link</option>
{locationOk && !locations.includes(filters.location) && (
<option value={filters.location}>{filters.location}</option>
)}
{locations.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
{showLinkFilters && (
<>
<div className="form-field">
<label htmlFor="inbox-f-linkedin">LinkedIn link</label>
<select
id="inbox-f-linkedin"
value={filters.hasLinkedin}
onChange={(e) => onChange('hasLinkedin', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Has a LinkedIn link</option>
<option value="no">No LinkedIn link</option>
</select>
{/* The form field is free text and nothing validates it on the way
in, so this matches on the domain and cannot promise the link
actually resolves to a profile. Say so rather than imply more. */}
<span className="cell-sub">Matches the link text, not a verified profile.</span>
</div>
<div className="form-field">
<label htmlFor="inbox-f-resume">Resume link</label>
<select
id="inbox-f-resume"
value={filters.hasResume}
onChange={(e) => onChange('hasResume', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Has a resume link</option>
<option value="no">No resume link</option>
</select>
</div>
</>
)}
</div>
)}
</div>
@ -985,14 +1052,14 @@ export default function Inbox() {
// '' means no filter; 'yes' / 'no' are both real filters. Finding the rows
// MISSING a link is half the reason this exists, so 'no' cannot collapse into
// "unset" the way a checkbox would force it to.
const [formFilters, setFormFilters] = useState(EMPTY_FORM_FILTERS)
const [showFormFilters, setShowFormFilters] = useState(false)
const activeFormFilters = Object.values(formFilters).filter(Boolean).length
const setFormFilter = useCallback((key, value) => {
setFormFilters((f) => ({ ...f, [key]: value }))
const [inboxFilters, setInboxFilters] = useState(EMPTY_INBOX_FILTERS)
const [showInboxFilters, setShowInboxFilters] = useState(false)
const setInboxFilter = useCallback((key, value) => {
setInboxFilters((f) => ({ ...f, [key]: value }))
setSkip(0) // page 4 of the old result set is meaningless in the new one
setSelectedId(null)
}, [])
const city = inboxFilters.location
const deepOpen = searchParams.get('open')
const deepKind = searchParams.get('kind')
@ -1010,12 +1077,16 @@ export default function Inbox() {
}, [deepOpen, deepKind, setSearchParams])
const isForms = channel === 'forms'
// Combined channel: each source is fetched with the UI page size, then
// merged newest-first. Per-source pages are not a perfect global timeline,
// but omitting limit dumped the whole form_data table on open.
// Combined channel: email and form lists both arrive newest created_at
// first; the merge uses that same clock so a June form cannot sit above a
// later email just because it was on the first sheet page.
const isAllChannel = channel === 'all'
const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS
const pageLimit = pageSize === 'all' ? undefined : pageSize
const activeInboxFilters = [
inboxFilters.location,
...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []),
].filter(Boolean).length
const tabFilter = TAB_FILTERS[tab] ?? {}
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
@ -1025,7 +1096,8 @@ export default function Inbox() {
top: pageLimit,
skip: pageLimit == null ? 0 : skip,
...(search ? { search } : {}),
}), [tabFilter, skip, pageLimit, search])
...(city ? { city } : {}),
}), [tabFilter, skip, pageLimit, search, city])
/**
* Sheet Forms only. On the All channel these rows are merged with email ones,
@ -1035,9 +1107,9 @@ export default function Inbox() {
* the inbox endpoint the same filters. Separate change.
*/
const linkFilters = useMemo(() => (isForms ? {
...(formFilters.hasLinkedin ? { hasLinkedin: formFilters.hasLinkedin === 'yes' } : {}),
...(formFilters.hasResume ? { hasResume: formFilters.hasResume === 'yes' } : {}),
} : {}), [isForms, formFilters])
...(inboxFilters.hasLinkedin ? { hasLinkedin: inboxFilters.hasLinkedin === 'yes' } : {}),
...(inboxFilters.hasResume ? { hasResume: inboxFilters.hasResume === 'yes' } : {}),
} : {}), [isForms, inboxFilters])
const formParams = useMemo(() => ({
// All channel spans every sheet tab, not just the selected one.
@ -1046,8 +1118,19 @@ export default function Inbox() {
limit: pageLimit,
...formTabFilter,
...(search ? { search } : {}),
...(city ? { city } : {}),
...linkFilters,
}), [formSheet, skip, pageLimit, search, formTabFilter, isAllChannel, linkFilters])
}), [formSheet, skip, pageLimit, search, city, formTabFilter, isAllChannel, linkFilters])
const citiesQuery = useQuery({
queryKey: qk.mailbox.cities(),
queryFn: async () => {
const res = await inboxApi.listApplications({ cityList: true, top: 1 })
return Array.isArray(res?.cities) ? res.cities : []
},
staleTime: 5 * 60 * 1000,
})
const cities = citiesQuery.data ?? []
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
@ -1088,8 +1171,9 @@ export default function Inbox() {
const formCountsParams = useMemo(() => ({
sheet: formCountsSheet,
...(search ? { search } : {}),
...(city ? { city } : {}),
...linkFilters,
}), [formCountsSheet, search, linkFilters])
}), [formCountsSheet, search, city, linkFilters])
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts(formCountsParams),
queryFn: async () => {
@ -1135,14 +1219,17 @@ export default function Inbox() {
}
}, [isForms, formSheetsQuery.data, formSheet])
// All channel: one page from each source, merged newest-first.
// All channel: one page from each source, newest created_at first, then merged.
const mergedRows = useMemo(() => {
if (!isAllChannel) return null
const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : []
const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : []
return [...emails, ...forms].sort(
(a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0),
)
const when = (row) => {
const d = row.createdAt || row.received
const t = d instanceof Date ? d.getTime() : NaN
return Number.isFinite(t) ? t : 0
}
return [...emails, ...forms].sort((a, b) => when(b) - when(a))
}, [isAllChannel, applicationsQuery.data, formQuery.data])
const activeQuery = isAllChannel
@ -1183,6 +1270,7 @@ export default function Inbox() {
'All Applications': pick('all'),
Unread: pick('unread'),
Processed: pick('processed'),
'On-Hold': pick('on_hold'),
Rejected: pick('rejected'),
Duplicates: pick('duplicates'),
}
@ -1200,7 +1288,7 @@ export default function Inbox() {
const searchTotal = isAllChannel
? (Number(applicationsQuery.data?.total ?? 0) + Number(formQuery.data?.total ?? 0))
: (activeQuery.data?.total ?? 0)
const total = search
const total = (search || city)
? searchTotal
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
@ -1297,7 +1385,7 @@ export default function Inbox() {
setQ('')
setSearch('') // clear the committed term too, or the new channel's first
// fetch carries the old channel's search for 300ms
setFormFilters(EMPTY_FORM_FILTERS)
setInboxFilters((f) => ({ ...EMPTY_INBOX_FILTERS, location: f.location }))
// Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear()
@ -1427,14 +1515,15 @@ export default function Inbox() {
{ header: 'Notice period', key: 'notice', width: 13 },
{ header: 'ATS score', key: 'ats', width: 10 },
{ header: 'Assigned job', key: 'job', width: 24 },
{ header: 'Suggested jobs', key: 'suggested', width: 40 },
],
rows: rows.map((r) => ({
name: r.name, email: r.email, phone: r.phone, position: r.position,
channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
source: r.source,
received: r.received ? fmtDate(r.received) : '',
status: r.processing, city: r.residingCity, notice: r.noticePeriod,
ats: r.atsScore, job: r.assignedPost?.title,
status: r.processing, city: r.city || r.residingCity, notice: r.noticePeriod,
ats: r.atsScore, job: assignedJobTitle(r), suggested: suggestedJobTitles(r),
})),
})
toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success')
@ -1609,16 +1698,16 @@ export default function Inbox() {
placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'}
/>
</div>
{isForms && (
<FormLinkFilters
filters={formFilters}
active={activeFormFilters}
open={showFormFilters}
onToggle={() => setShowFormFilters((s) => !s)}
onChange={setFormFilter}
onClear={() => { setFormFilters(EMPTY_FORM_FILTERS); setSkip(0); setSelectedId(null) }}
/>
)}
<InboxFilters
filters={inboxFilters}
cities={cities}
showLinkFilters={isForms}
active={activeInboxFilters}
open={showInboxFilters}
onToggle={() => setShowInboxFilters((s) => !s)}
onChange={setInboxFilter}
onClear={() => { setInboxFilters(EMPTY_INBOX_FILTERS); setSkip(0); setSelectedId(null) }}
/>
<QueueFreshness at={updatedAt} refreshing={refreshing} onRefresh={refreshQueue} />
</div>
<div className="inbox-list-body">
@ -1730,8 +1819,8 @@ export default function Inbox() {
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{formatRole(i.applicationStatus)}</Badge>
)}
{i.kind === 'form' && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span>
{(i.city || i.residingCity) && (
<span className="cell-sub">{i.city || i.residingCity}</span>
)}
</div>
</div>