diff --git a/backend/employment_agent/decorators.py b/backend/employment_agent/decorators.py index 614a7d3..49128ae 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_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"), } diff --git a/backend/employment_agent/execute_agent.py b/backend/employment_agent/execute_agent.py index debac70..b240284 100644 --- a/backend/employment_agent/execute_agent.py +++ b/backend/employment_agent/execute_agent.py @@ -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, } diff --git a/backend/employment_agent/prompt.py b/backend/employment_agent/prompt.py index 070367f..aa7ffb1 100644 --- a/backend/employment_agent/prompt.py +++ b/backend/employment_agent/prompt.py @@ -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/ 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. """ diff --git a/backend/g_sheet/app.py b/backend/g_sheet/app.py index a181ff1..583b479 100644 --- a/backend/g_sheet/app.py +++ b/backend/g_sheet/app.py @@ -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: diff --git a/backend/g_sheet/enums.py b/backend/g_sheet/enums.py index 3918bf5..55e3f46 100644 --- a/backend/g_sheet/enums.py +++ b/backend/g_sheet/enums.py @@ -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" diff --git a/backend/g_sheet/models.py b/backend/g_sheet/models.py index 1be1a5d..b3d4451 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_ +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,13 +346,14 @@ 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) 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: @@ -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"), diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 3764182..e232cd9 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -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): diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 2052cf0..ed14f85 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -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: diff --git a/backend/inbox/models.py b/backend/inbox/models.py index c27dba4..9ddf3e2 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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,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, 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()), search, isread, application_status, assigned, is_duplicate, - no_suggestions, processing_state, + no_suggestions, processing_state, city, ) if skip: statement = statement.offset(skip) @@ -1096,15 +1163,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 +1283,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 +1292,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 +1309,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 +1321,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), } diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index cde7ac2..1b46c42 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -141,6 +141,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, diff --git a/backend/inbox/tasks.py b/backend/inbox/tasks.py index bb74a22..a0784d0 100644 --- a/backend/inbox/tasks.py +++ b/backend/inbox/tasks.py @@ -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 "", diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 2e58b43..906aa90 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -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 diff --git a/backend/job/app.py b/backend/job/app.py index caee209..a84023c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index d4027aa..cacd2cb 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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() diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 5af8097..d0cdc73 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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: diff --git a/backend/migrations/manual/031_candidate_city_reapplied.sql b/backend/migrations/manual/031_candidate_city_reapplied.sql new file mode 100644 index 0000000..d579bd1 --- /dev/null +++ b/backend/migrations/manual/031_candidate_city_reapplied.sql @@ -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); diff --git a/backend/users/models.py b/backend/users/models.py index 323f394..be94d9c 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -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 diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 6cc2dbc..cc7dd52 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 } = {}) { +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, }, }) diff --git a/frontend/src/api/sheet.js b/frontend/src/api/sheet.js index 29a436f..73362ea 100644 --- a/frontend/src/api/sheet.js +++ b/frontend/src/api/sheet.js @@ -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 }, }) } diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 5a9ccbd..0b8e962 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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'], diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index c2c3d2d..5e0e788 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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. @@ -291,7 +324,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 || '', @@ -503,11 +537,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 +882,23 @@ 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 }) { +function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) { return (
)} @@ -985,14 +1044,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') @@ -1016,6 +1075,10 @@ export default function Inbox() { 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 +1088,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 +1099,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 +1110,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 +1163,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 () => { @@ -1183,6 +1259,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 +1277,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 +1374,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 +1504,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 +1687,16 @@ export default function Inbox() { placeholder={isForms ? 'Search name, email, position, city…' : 'Search applications…'} /> - {isForms && ( - setShowFormFilters((s) => !s)} - onChange={setFormFilter} - onClear={() => { setFormFilters(EMPTY_FORM_FILTERS); setSkip(0); setSelectedId(null) }} - /> - )} + setShowInboxFilters((s) => !s)} + onChange={setInboxFilter} + onClear={() => { setInboxFilters(EMPTY_INBOX_FILTERS); setSkip(0); setSelectedId(null) }} + />
@@ -1730,8 +1808,8 @@ export default function Inbox() { {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {formatRole(i.applicationStatus)} )} - {i.kind === 'form' && i.residingCity && ( - {i.residingCity} + {(i.city || i.residingCity) && ( + {i.city || i.residingCity} )}