commit
9036a48098
|
|
@ -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"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue