filter appliede
CI / checks (push) Failing after 1m57s Details
CI / checks (pull_request) Failing after 1m53s Details

pull/79/head
ahmed.mujtaba 2026-09-07 18:42:15 +05:00
parent 9036a48098
commit d00ebcf6f5
13 changed files with 394 additions and 94 deletions

View File

@ -209,6 +209,8 @@ async def fetch_form_data(
has_linkedin: bool | None = Query(None), has_linkedin: bool | None = Query(None),
has_resume: bool | None = Query(None), has_resume: bool | None = Query(None),
city: str | None = Query(None), city: str | None = Query(None),
source: str | None = Query(None),
assigned: bool | None = Query(None),
no_suggestions: bool | None = Query(None), no_suggestions: bool | None = Query(None),
offset: int = Query(0,ge=0), offset: int = Query(0,ge=0),
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged. # Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
@ -222,7 +224,8 @@ async def fetch_form_data(
sheet=sheet,search=search,offset=offset,limit=limit, sheet=sheet,search=search,offset=offset,limit=limit,
processing_state=processing_state,is_duplicate=is_duplicate, 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_values(city),no_suggestions=no_suggestions, city=_city_values(city),source=(source or "").strip() or None,
assigned=assigned,no_suggestions=no_suggestions,
) )
return JSONResponse(content={"data":items,"total":total,"status_code":200}) return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException: except HTTPException:
@ -242,6 +245,8 @@ async def fetch_form_data_counts(
has_linkedin: bool | None = Query(None), has_linkedin: bool | None = Query(None),
has_resume: bool | None = Query(None), has_resume: bool | None = Query(None),
city: str | None = Query(None), city: str | None = Query(None),
source: str | None = Query(None),
assigned: bool | None = Query(None),
current_user: dict = Depends(_FORM_DATA_READ), current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -249,7 +254,7 @@ async def fetch_form_data_counts(
service=SheetFormData(session=session) service=SheetFormData(session=session)
data=await service.get_counts( data=await service.get_counts(
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume, sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city), city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
) )
return JSONResponse(content={"data":data,"total":1,"status_code":200}) return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException: except HTTPException:

View File

@ -129,7 +129,8 @@ class FormData(SQLModel, table=True):
@classmethod @classmethod
def _filters( def _filters(
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None, cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
has_linkedin=None, has_resume=None, city=None, no_suggestions=None, inbox_filter=None, has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None,
): ):
filters = [] filters = []
if sheet: if sheet:
@ -161,6 +162,16 @@ class FormData(SQLModel, table=True):
if cities: if cities:
city_col = func.lower(func.coalesce(cls.city, cls.residing_city)) city_col = func.lower(func.coalesce(cls.city, cls.residing_city))
filters.append(city_col.in_([c.lower() for c in cities])) filters.append(city_col.in_([c.lower() for c in cities]))
if source:
text = source.strip()
lowered = text.lower()
if lowered not in ("google sheet", "google_sheet", "sheet"):
filters.append(cls.source_of_application.ilike(f"%{text}%"))
if assigned is True:
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
elif assigned is False:
filters.append(cls.assigned_job_post_id.is_(None))
filters.append(cls.job_post_id.is_(None))
if no_suggestions is True: if no_suggestions is True:
filters.append(cls._no_suggested_jobs()) filters.append(cls._no_suggested_jobs())
if inbox_filter == "matched": if inbox_filter == "matched":
@ -346,7 +357,8 @@ class FormData(SQLModel, table=True):
async def fetch_form_data( async def fetch_form_data(
cls, session: AsyncSession, *, sheet=None, search=None, cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None, processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, city=None, no_suggestions=None, inbox_filter=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None,
offset=0, limit=None, offset=0, limit=None,
): ):
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc()) statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
@ -354,7 +366,8 @@ class FormData(SQLModel, table=True):
sheet=sheet, search=search, sheet=sheet, search=search,
processing_state=processing_state, is_duplicate=is_duplicate, 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, inbox_filter=inbox_filter, city=city, source=source, assigned=assigned,
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
): ):
statement = statement.where(clause) statement = statement.where(clause)
if offset: if offset:
@ -441,14 +454,16 @@ class FormData(SQLModel, table=True):
async def count_form_data( async def count_form_data(
cls, session: AsyncSession, *, sheet=None, search=None, cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None, processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, city=None, no_suggestions=None, inbox_filter=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None,
): ):
statement = select(func.count()).select_from(cls) statement = select(func.count()).select_from(cls)
for clause in cls._filters( for clause in cls._filters(
sheet=sheet, search=search, sheet=sheet, search=search,
processing_state=processing_state, is_duplicate=is_duplicate, 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, inbox_filter=inbox_filter, city=city, source=source, assigned=assigned,
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
): ):
statement = statement.where(clause) statement = statement.where(clause)
result = await session.execute(statement) result = await session.execute(statement)
@ -457,7 +472,7 @@ class FormData(SQLModel, table=True):
@classmethod @classmethod
async def count_processing( async def count_processing(
cls, session: AsyncSession, *, sheet=None, search=None, cls, session: AsyncSession, *, sheet=None, search=None,
has_linkedin=None, has_resume=None, city=None, has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
): ):
"""Tab badge counts for the Sheet Forms channel. """Tab badge counts for the Sheet Forms channel.
@ -483,6 +498,7 @@ class FormData(SQLModel, table=True):
for clause in cls._filters( for clause in cls._filters(
sheet=sheet, search=search, sheet=sheet, search=search,
has_linkedin=has_linkedin, has_resume=has_resume, city=city, has_linkedin=has_linkedin, has_resume=has_resume, city=city,
source=source, assigned=assigned,
): ):
statement = statement.where(clause) statement = statement.where(clause)
row = (await session.execute(statement)).one() row = (await session.execute(statement)).one()
@ -512,6 +528,16 @@ class FormData(SQLModel, table=True):
) )
return [text.strip() for text in result.scalars().all() if (text or "").strip()] return [text.strip() for text in result.scalars().all() if (text or "").strip()]
@classmethod
async def distinct_sources(cls, session: AsyncSession):
"""Non-blank source_of_application values. Distinct only within form_data."""
result = await session.execute(
select(cls.source_of_application)
.where(cls.source_of_application.is_not(None), cls.source_of_application != "")
.distinct()
)
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
@classmethod @classmethod
async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True): async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True):
count_result = await session.execute( count_result = await session.execute(

View File

@ -436,20 +436,20 @@ class SheetFormData(Sheet):
async def get_form_data( async def get_form_data(
self,sheet=None,search=None,offset=0,limit=None, self,sheet=None,search=None,offset=0,limit=None,
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None, processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
city=None,no_suggestions=None, city=None,source=None,assigned=None,no_suggestions=None,
): ):
session=self._require_session() session=self._require_session()
rows=await FormData.fetch_form_data( rows=await FormData.fetch_form_data(
session,sheet=sheet,search=search,offset=offset,limit=limit, session,sheet=sheet,search=search,offset=offset,limit=limit,
processing_state=processing_state,is_duplicate=is_duplicate, processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,city=city, has_linkedin=has_linkedin,has_resume=has_resume,city=city,
no_suggestions=no_suggestions, source=source,assigned=assigned,no_suggestions=no_suggestions,
) )
total=await FormData.count_form_data( total=await FormData.count_form_data(
session,sheet=sheet,search=search, session,sheet=sheet,search=search,
processing_state=processing_state,is_duplicate=is_duplicate, processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,city=city, has_linkedin=has_linkedin,has_resume=has_resume,city=city,
no_suggestions=no_suggestions, source=source,assigned=assigned,no_suggestions=no_suggestions,
) )
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows]) items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
from job.candidate.views import CandidateView from job.candidate.views import CandidateView
@ -600,10 +600,11 @@ class SheetFormData(Sheet):
raise HTTPException(status_code=404,detail="Form data not found") raise HTTPException(status_code=404,detail="Form data not found")
return await self.get_form_data_by_id(record_id) return await self.get_form_data_by_id(record_id)
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None): async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None):
return await FormData.count_processing( return await FormData.count_processing(
self._require_session(),sheet=sheet,search=search, self._require_session(),sheet=sheet,search=search,
has_linkedin=has_linkedin,has_resume=has_resume,city=city, has_linkedin=has_linkedin,has_resume=has_resume,city=city,
source=source,assigned=assigned,
) )
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None): async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):

View File

@ -26,10 +26,12 @@ def _city_values(city: str | None):
return parts or None return parts or None
def _apps_payload(items,total,cities=None): def _apps_payload(items,total,cities=None,sources=None):
body={"data":items,"total":total,"status_code":200} body={"data":items,"total":total,"status_code":200}
if cities is not None: if cities is not None:
body["cities"]=cities body["cities"]=cities
if sources is not None:
body["sources"]=sources
return JSONResponse(content=body) return JSONResponse(content=body)
@ -97,6 +99,8 @@ class ReadAllBody(BaseModel):
is_duplicate: bool | None = None is_duplicate: bool | None = None
no_suggestions: bool | None = None no_suggestions: bool | None = None
processing_state: str | None = None processing_state: str | None = None
city: str | None = None
source: str | None = None
class TriageOverrideBody(BaseModel): class TriageOverrideBody(BaseModel):
@ -303,6 +307,8 @@ async def mark_all_inbox_read(
is_duplicate=payload.is_duplicate, is_duplicate=payload.is_duplicate,
no_suggestions=payload.no_suggestions, no_suggestions=payload.no_suggestions,
processing_state=payload.processing_state, processing_state=payload.processing_state,
city=_city_values(payload.city),
source=(payload.source or "").strip() or None,
) )
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException: except HTTPException:
@ -338,6 +344,7 @@ async def get_all_applications(
processing_state: str | None = Query(default=None), processing_state: str | None = Query(default=None),
search: str | None = Query(None), search: str | None = Query(None),
city: str | None = Query(None), city: str | None = Query(None),
source: str | None = Query(None),
city_list: bool = Query(default=False), city_list: bool = Query(default=False),
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged. # Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
top: int | None = Query(None, ge=1, le=500), top: int | None = Query(None, ge=1, le=500),
@ -348,23 +355,25 @@ async def get_all_applications(
try: try:
service=Email(session=session) service=Email(session=session)
city_values=_city_values(city) city_values=_city_values(city)
source_value=(source or "").strip() or None
cities=await service.list_cities() if city_list else None cities=await service.list_cities() if city_list else None
sources=await service.list_sources() if city_list else None
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state: if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
return _apps_payload(items,total,cities) return _apps_payload(items,total,cities,sources)
if isread==False: if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values) total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
return _apps_payload(items,total,cities) return _apps_payload(items,total,cities,sources)
if record_id: if record_id:
item=await service.get_application_by_id(record_id) item=await service.get_application_by_id(record_id)
return _apps_payload(item,1,cities) return _apps_payload(item,1,cities,sources)
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values) items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values) total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
return _apps_payload(items,total,cities) return _apps_payload(items,total,cities,sources)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -9,7 +9,7 @@ from dotenv import load_dotenv
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status from inbox.enums import Candidate_application_Status
from role.models import EnumRoles, Roles from role.models import EnumRoles, Roles
from sqlalchemy import Column, DateTime, case, func, or_, update from sqlalchemy import Column, DateTime, case, false, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -1031,6 +1031,7 @@ class Inbox_Messages(SQLModel, table=True):
no_suggestions: bool | None=None, no_suggestions: bool | None=None,
processing_state: str | None=None, processing_state: str | None=None,
city=None, city=None,
source: str | None=None,
inbox_filter: str | None=None, inbox_filter: str | None=None,
): ):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE. """The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -1061,6 +1062,23 @@ class Inbox_Messages(SQLModel, table=True):
cities = [c.strip() for c in (city or []) if (c or "").strip()] cities = [c.strip() for c in (city or []) if (c or "").strip()]
if cities: if cities:
statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities])) statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities]))
if source:
text = source.strip()
lowered = text.lower()
if lowered in ("google sheet", "google_sheet", "sheet"):
statement = statement.where(false())
else:
key = lowered.replace(" ", "_")
channel_ids = select(SourceChannels.id).where(
or_(
func.lower(SourceChannels.label) == lowered,
SourceChannels.key == key,
)
)
statement = statement.where(or_(
cls.source_channel_id.in_(channel_ids),
cls.message_to.ilike(f"%{text}%"),
))
if inbox_filter == "matched": if inbox_filter == "matched":
statement = statement.where(cls.match_status == "matched") statement = statement.where(cls.match_status == "matched")
elif inbox_filter == "unassigned": elif inbox_filter == "unassigned":
@ -1076,12 +1094,12 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod @classmethod
async def get_inbox_messages( async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, light: bool=False cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False
): ):
statement = cls._apply_filters( statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc(), cls.id.desc()), select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, city, no_suggestions, processing_state, city, source,
) )
if skip: if skip:
statement = statement.offset(skip) statement = statement.offset(skip)
@ -1162,11 +1180,11 @@ class Inbox_Messages(SQLModel, table=True):
return {str(job_id): int(n) for job_id, n in result.all()} return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod @classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None): async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None):
statement = cls._apply_filters( statement = cls._apply_filters(
select(func.count()).select_from(cls), select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate, search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, city, no_suggestions, processing_state, city, source,
) )
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@ -1283,6 +1301,8 @@ class Inbox_Messages(SQLModel, table=True):
is_duplicate: bool | None=None, is_duplicate: bool | None=None,
processing_state: str | None=None, processing_state: str | None=None,
no_suggestions: bool | None=None, no_suggestions: bool | None=None,
city=None,
source: str | None=None,
) -> int: ) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED. """Mark every row matching a list filter. Returns rows actually CHANGED.
@ -1291,7 +1311,10 @@ class Inbox_Messages(SQLModel, table=True):
was already read. It also keeps read_overridden_at off rows nobody decided 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. 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,no_suggestions,processing_state) statement=cls._apply_filters(
update(cls),search,isread,application_status,assigned,is_duplicate,
no_suggestions,processing_state,city,source,
)
statement=statement.where(cls.message_read!=bool(read)) statement=statement.where(cls.message_read!=bool(read))
result=await session.execute( result=await session.execute(
statement.values(message_read=bool(read),read_overridden_at=_now()) statement.values(message_read=bool(read),read_overridden_at=_now())
@ -1834,6 +1857,49 @@ class AtsResults(SQLModel, table=True):
) )
return result.scalars().first() return result.scalars().first()
@classmethod
async def get_latest_by_job_for_inbox(cls, session: AsyncSession, inbox_id):
"""Newest score per job_post_id for one inbox row (current or superseded)."""
if inbox_id is None:
return []
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id))
.order_by(cls.computed_at.desc())
)
by_job = {}
for row in result.scalars():
jid = str(row.job_post_id) if row.job_post_id else None
if jid and jid not in by_job:
by_job[jid] = row
return list(by_job.values())
@classmethod
async def get_latest_by_job_for_messages(cls, session: AsyncSession, message_ids) -> dict:
"""{str(message_id): [newest score per job]} for a page of inbox messages."""
keys = []
for raw in message_ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
keys.append(uid)
if not keys:
return {}
result = await session.execute(
select(cls, Inbox.message_id)
.join(Inbox, cls.inbox_id == Inbox.id)
.where(Inbox.message_id.in_(keys))
.order_by(cls.computed_at.desc())
)
grouped = {}
for row, mid in result.all():
if mid is None:
continue
bucket = grouped.setdefault(mid, {})
jid = str(row.job_post_id) if row.job_post_id else None
if jid and jid not in bucket:
bucket[jid] = row
return {str(mid): list(jobs.values()) for mid, jobs in grouped.items()}
@classmethod @classmethod
async def get_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id): async def get_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id):
"""Any score for this form_data row against this job — current or superseded.""" """Any score for this form_data row against this job — current or superseded."""

View File

@ -82,6 +82,18 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
"match_error": message.match_error, "match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None, "matched_at": message.matched_at.isoformat() if message.matched_at else None,
"resume_text": message.resume_text, "resume_text": message.resume_text,
"ats_score": message.ats_score,
"ats_band": message.ats_band or None,
}
def serialize_ats_result(row) -> dict:
"""One inbox ats_results row — same keys the Sheet Forms cards paint."""
return {
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"overall_score": row.overall_score,
"band": row.band or None,
"computed_at": row.computed_at.isoformat() if row.computed_at else None,
} }

View File

@ -39,6 +39,8 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
except ValueError: except ValueError:
raise PermanentTaskError("record_id and job_id must be uuids") raise PermanentTaskError("record_id and job_id must be uuids")
already=False
results=[]
async with session_scope() as session: async with session_scope() as session:
link=await Inbox.get_inbox_by_message_id(session,mid) link=await Inbox.get_inbox_by_message_id(session,mid)
if link is not None: if link is not None:
@ -46,22 +48,65 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
# a user, so already_scored must not depend on it. # a user, so already_scored must not depend on it.
existing=await AtsResults.get_for_inbox_job(session,link.id,jid) existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
if existing is not None: if existing is not None:
return {"status":"already_scored"} already=True
job=await JobPosts.get_job_post_by_id(session,job_id) if not already:
if job is None or job.is_deleted: job=await JobPosts.get_job_post_by_id(session,job_id)
raise PermanentTaskError("job post missing or deleted") if job is None or job.is_deleted:
service=CandidateScoring(session=session) raise PermanentTaskError("job post missing or deleted")
try: service=CandidateScoring(session=session)
# Attribute the rows to the job's owner — there is no request user try:
# in a background task. # Attribute the rows to the job's owner — there is no request user
results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)}) # in a background task.
except HTTPException as exc: results=await service.score_inbox(job_id,[record_id],{"id":str(job.created_by)})
# 400/404 from score_inbox are permanent (no attachment, bad ids); except HTTPException as exc:
# retrying cannot fix them. # 400/404 from score_inbox are permanent (no attachment, bad ids);
raise PermanentTaskError(str(exc.detail)) from exc # retrying cannot fix them.
raise PermanentTaskError(str(exc.detail)) from exc
if already:
# Assigning a job already scored as a suggestion must still flip the
# denormed chip from max-of-suggestions to that job.
await denorm_message_ats_score(record_id)
return {"status":"already_scored"}
await denorm_message_ats_score(record_id)
return {"status":"scored","results":len(results)} return {"status":"scored","results":len(results)}
async def denorm_message_ats_score(record_id:str) -> None:
"""Stamp inbox_messages.ats_score: assigned job if set, else max of suggestions."""
async with session_scope() as session:
msg=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
if msg is None:
return
link=await Inbox.get_inbox_by_message_id(session,record_id)
if link is None:
return
rows=await AtsResults.get_latest_by_job_for_inbox(session,link.id)
if not rows:
return
chosen=None
assigned=msg.assigned_job_post_id
if assigned:
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
if chosen is None:
chosen=max(rows,key=lambda r:float(r.overall_score or 0))
await Inbox_Messages.set_ats_score(session,record_id,chosen.overall_score,chosen.band)
async def score_message_against_jobs(record_id:str,job_ids) -> None:
"""Score one inbox CV against each job. Failures do not abort the rest."""
seen=set()
for raw in job_ids or []:
job_id=str(raw or "").strip()
if not job_id or job_id in seen:
continue
seen.add(job_id)
try:
outcome=await score_message_against_job(record_id,job_id)
logger.info("ats auto-score %s vs %s: %s",record_id,job_id,outcome.get("status"))
except Exception as exc:
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,job_id,exc)
@broker.task( @broker.task(
task_name="g_sheet.score_form", task_name="g_sheet.score_form",
retry_on_error=True, retry_on_error=True,
@ -166,23 +211,18 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
status=status, status=status,
error=result.get("error") or "", error=result.get("error") or "",
) )
# Auto-score: the match just paired this CV with jobs, so run the ATS on the # Auto-score every suggested job. The list chip is the max until a recruiter
# spot — assigned job first, else the agent's top suggestion. Scoring failures # assigns one; assignment then re-runs ATS against that job_post_id.
# must not fail the match; the match result is already committed above. # Scoring failures must not fail the match; the match result is committed above.
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j] suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
score_job_id=None score_job_ids=list(suggested)
async with session_scope() as session: async with session_scope() as session:
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id) fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
if fresh is not None and fresh.assigned_job_post_id: if fresh is not None and fresh.assigned_job_post_id:
score_job_id=str(fresh.assigned_job_post_id) assigned=str(fresh.assigned_job_post_id)
if score_job_id is None and suggested: if assigned not in score_job_ids:
score_job_id=suggested[0] score_job_ids.append(assigned)
if score_job_id: await score_message_against_jobs(record_id,score_job_ids)
try:
outcome=await score_message_against_job(record_id,score_job_id)
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
except Exception as exc:
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
return { return {
"status":status, "status":status,

View File

@ -4,9 +4,9 @@ import uuid
import httpx,os import httpx,os
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status from inbox.enums import Candidate_application_Status
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox,SourceChannels,AtsResults
from inbox.file_decoder import extract_pdf_attachments from inbox.file_decoder import extract_pdf_attachments
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run, serialize_ats_result
from inbox.plugins import ( from inbox.plugins import (
EMAIL_API_TOKEN, EMAIL_API_TOKEN,
attach_email_pdfs_to_s3, attach_email_pdfs_to_s3,
@ -272,15 +272,16 @@ class Email:
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id) item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
else: else:
item["assigned_job_post"]=None item["assigned_job_post"]=None
return await cv.attach_application_history(item) items=await self._paint_inbox_ats([item])
return await cv.attach_application_history(items[0])
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None): async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
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: if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
elif isread==False: elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
else: else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True) messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) 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=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
items=await self._attach_job_posts(items) items=await self._attach_job_posts(items)
@ -323,6 +324,46 @@ class Email:
else: else:
suggested.append(dict(payload)) suggested.append(dict(payload))
item["suggested_job_posts"]=suggested item["suggested_job_posts"]=suggested
items=await self._paint_inbox_ats(items)
return items
async def _paint_inbox_ats(self,items):
"""Attach per-job ATS scores onto suggested/assigned posts and stamp max.
Unassigned rows show the highest suggestion score; assigned rows show
the score against assigned_job_post_id same rule as Sheet Forms.
"""
if not items:
return items
by_msg=await AtsResults.get_latest_by_job_for_messages(
self.session,[item.get("id") for item in items],
)
for item in items:
rows=by_msg.get(str(item.get("id") or "")) or []
scores=[serialize_ats_result(r) for r in rows]
item["ats_results"]=scores
score_by_job={
str(s["job_post_id"]):s for s in scores if s.get("job_post_id")
}
for post in item.get("suggested_job_posts") or []:
hit=score_by_job.get(str(post.get("id")))
if hit:
post["overall_score"]=hit.get("overall_score")
post["band"]=hit.get("band")
assigned=item.get("assigned_job_post")
if assigned:
hit=score_by_job.get(str(assigned.get("id")))
if hit:
assigned["overall_score"]=hit.get("overall_score")
assigned["band"]=hit.get("band")
aid=item.get("assigned_job_post_id")
assigned_score=score_by_job.get(str(aid)) if aid else None
if assigned_score and assigned_score.get("overall_score") is not None:
item["ats_score"]=round(float(assigned_score.get("overall_score")))
else:
nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None]
if nums:
item["ats_score"]=round(float(max(nums)))
return items return items
async def get_application_by_id(self,record_id): async def get_application_by_id(self,record_id):
@ -478,13 +519,13 @@ class Email:
results.append({"email":email,"sent":False}) results.append({"email":email,"sent":False})
return results return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None): async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state: if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city) return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
elif isread==False: elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city) return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
else: else:
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city) return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
async def list_cities(self): async def list_cities(self):
"""Distinct cities from inbox_messages and form_data, merged in Python.""" """Distinct cities from inbox_messages and form_data, merged in Python."""
@ -493,6 +534,15 @@ class Email:
forms=await FormData.distinct_cities(self.session) forms=await FormData.distinct_cities(self.session)
return Reapplied(session=self.session).merge_cities(inbox,forms) return Reapplied(session=self.session).merge_cities(inbox,forms)
async def list_sources(self):
"""Source / platform labels: seeded channels, Google Sheet, form sources."""
from g_sheet.models import FormData
channels=await SourceChannels.list_active(self.session)
forms=await FormData.distinct_sources(self.session)
return Reapplied(session=self.session).merge_cities(
[c.label for c in channels],["Google Sheet"],forms,
)
async def assign_job_post(self,record_id,job_post_id): async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message: if not message:
@ -550,7 +600,8 @@ class Email:
async def set_read_all(self,read,search=None,isread:bool=True, async def set_read_all(self,read,search=None,isread:bool=True,
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None): assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,
city=None,source=None):
"""Mark every row the SAME filter set would have listed. """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 The filter arguments are the caller's current view, not a free-form query: the
@ -561,6 +612,7 @@ class Email:
self.session,read,search=search,isread=isread, self.session,read,search=search,isread=isread,
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate, application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
no_suggestions=no_suggestions,processing_state=processing_state, no_suggestions=no_suggestions,processing_state=processing_state,
city=city,source=source,
) )
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s", 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) updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)

View File

@ -20,7 +20,7 @@ export function listMessages() {
* `assigned` is tri-valued: omit for no filter, true for rows with an * `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue. * assigned_job_post_id, false for the Job Matching queue.
*/ */
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, cityList } = {}) { export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source, cityList } = {}) {
return request('/inbox/all-applications', { return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults // `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 // to true = no filter), send false for the Unread tab only. buildUrl drops
@ -31,8 +31,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// `no_suggestions`: Inbox On-Hold tab — no suggested job post linked. // `no_suggestions`: Inbox On-Hold tab — no suggested job post linked.
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes // `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
// processed, not application_status PROCESS). // processed, not application_status PROCESS).
// `city`: optional comma-separated list. `city_list`: include merged distinct // `city`: optional comma-separated list. `source`: channel / platform label.
// cities from inbox_messages and form_data on the same response. // `city_list`: include merged distinct cities and sources on the same response.
params: { params: {
search, search,
top, top,
@ -45,6 +45,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
no_suggestions: noSuggestions, no_suggestions: noSuggestions,
processing_state: processingState, processing_state: processingState,
city, city,
source,
city_list: cityList, city_list: cityList,
}, },
}) })
@ -128,7 +129,7 @@ export function bulkSetRead(recordIds, read) {
* Resolves to `{updated, read}`, where `updated` counts rows that actually * Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast. * CHANGED state, so it is safe to show in a toast.
*/ */
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) { export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source } = {}) {
return request('/inbox/read-all', { return request('/inbox/read-all', {
method: 'PATCH', method: 'PATCH',
body: { body: {
@ -140,6 +141,8 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
is_duplicate: isDuplicate, is_duplicate: isDuplicate,
no_suggestions: noSuggestions, no_suggestions: noSuggestions,
processing_state: processingState, processing_state: processingState,
city,
source,
}, },
}) })
} }

View File

@ -24,12 +24,12 @@ export function listFormDataSheets() {
*/ */
export function listFormData({ export function listFormData({
sheet, search, offset = 0, limit, processing_state, is_duplicate, sheet, search, offset = 0, limit, processing_state, is_duplicate,
hasLinkedin, hasResume, city, no_suggestions, hasLinkedin, hasResume, city, source, assigned, no_suggestions,
} = {}) { } = {}) {
return request('/sheet/form-data/fetch', { return request('/sheet/form-data/fetch', {
params: { params: {
sheet, search, offset, limit, processing_state, is_duplicate, sheet, search, offset, limit, processing_state, is_duplicate,
has_linkedin: hasLinkedin, has_resume: hasResume, city, no_suggestions, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions,
}, },
}) })
} }
@ -47,9 +47,9 @@ export function countFormData({ sheet } = {}) {
* processing_state or is_duplicate: those two ARE the tabs, and passing them * 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. * would make every badge report the tab the user is already on.
*/ */
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city } = {}) { export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned } = {}) {
return request('/sheet/form-data/counts', { return request('/sheet/form-data/counts', {
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city }, params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned },
}) })
} }

View File

@ -11,6 +11,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
// A group heading renders only if something under it survived the permission // A group heading renders only if something under it survived the permission
// filter otherwise a low-privilege user sees orphaned section labels. // filter otherwise a low-privilege user sees orphaned section labels.
const visible = ROUTES.filter((r) => { const visible = ROUTES.filter((r) => {
if (r.hidden) return false
if (!can(r.permission)) return false if (!can(r.permission)) return false
if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false
return true return true

View File

@ -18,7 +18,7 @@ export const ROUTES = [
// --- Workspace --- // --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' }, { path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' }, { path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' }, { path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching', hidden: true },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' }, { path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' }, { path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
// Replaces Talent Pool. That screen browsed candidate accounts over a seed // Replaces Talent Pool. That screen browsed candidate accounts over a seed

View File

@ -72,7 +72,7 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
const SEARCH_DEBOUNCE_MS = 300 const SEARCH_DEBOUNCE_MS = 300
/** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */ /** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */
const EMPTY_INBOX_FILTERS = { location: '', hasLinkedin: '', hasResume: '' } const EMPTY_INBOX_FILTERS = { location: '', source: '', assigned: '', hasLinkedin: '', hasResume: '' }
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */ /** "Updated 4 min ago" under the search box — the honest label for a cached list. */
function agoLabel(ts) { function agoLabel(ts) {
@ -254,6 +254,22 @@ function asAtsScore(value) {
return Number.isFinite(n) ? n : null return Number.isFinite(n) ? n : null
} }
/** Assigned score if set, else max of per-job suggestion scores. */
function emailAtsScore(row) {
const assignedId = row?.assigned_job_post_id || row?.assignedId
const results = Array.isArray(row?.ats_results) ? row.ats_results : []
const posts = Array.isArray(row?.suggested_job_posts)
? row.suggested_job_posts
: (Array.isArray(row?.suggestedPosts) ? row.suggestedPosts : [])
const assignedScore = results.find((s) => String(s.job_post_id) === String(assignedId))
const fromResults = results.map((s) => asAtsScore(s.overall_score)).filter((n) => n != null)
const fromJobs = posts.map((p) => asAtsScore(p.overall_score)).filter((n) => n != null)
return asAtsScore(row?.ats_score)
?? asAtsScore(assignedScore?.overall_score)
?? (fromResults.length ? Math.max(...fromResults) : null)
?? (fromJobs.length ? Math.max(...fromJobs) : null)
}
/** Assigned job title for export — list rows may carry the post or only an id + jobPosts. */ /** Assigned job title for export — list rows may carry the post or only an id + jobPosts. */
function assignedJobTitle(row) { function assignedJobTitle(row) {
const fromPost = row?.assignedPost?.title const fromPost = row?.assignedPost?.title
@ -493,6 +509,8 @@ async function fetchMessageDetail(recordId) {
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null, assignedPost: row.assigned_job_post || null,
atsResults: Array.isArray(row.ats_results) ? row.ats_results : [],
atsScore: emailAtsScore(row),
isReapplicant: Boolean(row.is_reapplicant), isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
} }
@ -536,7 +554,7 @@ async function fetchApplications(params) {
linkedinUrl: row.linkedin_url || '', linkedinUrl: row.linkedin_url || '',
// resume_text is not on the list payload (serialize_application // resume_text is not on the list payload (serialize_application
// light=True). The detail query fetches it for the open row. // light=True). The detail query fetches it for the open row.
atsScore: asAtsScore(row.ats_score), atsScore: emailAtsScore(row),
phone: row.phone, phone: row.phone,
experience: row.experience, experience: row.experience,
recruiter: row.recruiter, recruiter: row.recruiter,
@ -549,6 +567,7 @@ async function fetchApplications(params) {
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null, assignedPost: row.assigned_job_post || null,
atsResults: Array.isArray(row.ats_results) ? row.ats_results : [],
isReapplicant: Boolean(row.is_reapplicant), isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [], previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
} }
@ -892,10 +911,12 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
* `minmax(280px, 34%)`. On any wide screen it would try to fit four columns into * `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. * about four hundred pixels. This stacks instead, like the Progress sidebar.
* *
* Location is on every channel. LinkedIn / Resume stay Sheet Forms only those * City and Source / Platform are on every channel. Assigned job is too
* columns do not exist on email rows. Collapsed by default because extra selects * email and form rows both carry an assigned_job_post_id. LinkedIn / Resume
* eat scarce vertical space above the queue, but the toggle carries the active * stay Sheet Forms only those columns do not exist on email rows. Collapsed
* count so a collapsed panel cannot silently hide a filter. * 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. * (`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 * Both files are mid-edit elsewhere, so this is deliberately a local copy rather
@ -904,9 +925,10 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
/** Junk location tokens — hide from the dropdown, not from the list query. */ /** Junk location tokens — hide from the dropdown, not from the list query. */
const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i
function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) { function InboxFilters({ filters, cities, sources, showLinkFilters, active, open, onToggle, onChange, onClear }) {
const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim())) const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim()))
const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim()) const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim())
const platforms = sources || []
return ( return (
<div className="inbox-filters"> <div className="inbox-filters">
<button <button
@ -925,9 +947,9 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
{open && ( {open && (
<div className="inbox-filter-stack"> <div className="inbox-filter-stack">
<div className="form-field"> <div className="form-field">
<label htmlFor="inbox-f-location">Location</label> <label htmlFor="inbox-f-city">City</label>
<select <select
id="inbox-f-location" id="inbox-f-city"
value={filters.location} value={filters.location}
onChange={(e) => onChange('location', e.target.value)} onChange={(e) => onChange('location', e.target.value)}
> >
@ -940,6 +962,34 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
))} ))}
</select> </select>
</div> </div>
<div className="form-field">
<label htmlFor="inbox-f-source">Source / Platform</label>
<select
id="inbox-f-source"
value={filters.source || ''}
onChange={(e) => onChange('source', e.target.value)}
>
<option value="">Any</option>
{filters.source && !platforms.includes(filters.source) && (
<option value={filters.source}>{filters.source}</option>
)}
{platforms.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="form-field">
<label htmlFor="inbox-f-assigned">Assigned job</label>
<select
id="inbox-f-assigned"
value={filters.assigned || ''}
onChange={(e) => onChange('assigned', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Assigned</option>
<option value="no">Unassigned</option>
</select>
</div>
{showLinkFilters && ( {showLinkFilters && (
<> <>
<div className="form-field"> <div className="form-field">
@ -1060,6 +1110,12 @@ export default function Inbox() {
setSelectedId(null) setSelectedId(null)
}, []) }, [])
const city = inboxFilters.location const city = inboxFilters.location
const source = inboxFilters.source
const assignedParams = inboxFilters.assigned === 'yes'
? { assigned: true }
: inboxFilters.assigned === 'no'
? { assigned: false }
: {}
const deepOpen = searchParams.get('open') const deepOpen = searchParams.get('open')
const deepKind = searchParams.get('kind') const deepKind = searchParams.get('kind')
@ -1085,6 +1141,8 @@ export default function Inbox() {
const pageLimit = pageSize === 'all' ? undefined : pageSize const pageLimit = pageSize === 'all' ? undefined : pageSize
const activeInboxFilters = [ const activeInboxFilters = [
inboxFilters.location, inboxFilters.location,
inboxFilters.source,
inboxFilters.assigned,
...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []), ...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []),
].filter(Boolean).length ].filter(Boolean).length
@ -1097,7 +1155,9 @@ export default function Inbox() {
skip: pageLimit == null ? 0 : skip, skip: pageLimit == null ? 0 : skip,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(city ? { city } : {}), ...(city ? { city } : {}),
}), [tabFilter, skip, pageLimit, search, city]) ...(source ? { source } : {}),
...assignedParams,
}), [tabFilter, skip, pageLimit, search, city, source, assignedParams])
/** /**
* Sheet Forms only. On the All channel these rows are merged with email ones, * Sheet Forms only. On the All channel these rows are merged with email ones,
@ -1119,18 +1179,28 @@ export default function Inbox() {
...formTabFilter, ...formTabFilter,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(city ? { city } : {}), ...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
...linkFilters, ...linkFilters,
}), [formSheet, skip, pageLimit, search, city, formTabFilter, isAllChannel, linkFilters]) }), [formSheet, skip, pageLimit, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters])
const citiesQuery = useQuery({ const citiesQuery = useQuery({
queryKey: qk.mailbox.cities(), queryKey: qk.mailbox.cities(),
queryFn: async () => { queryFn: async () => {
const res = await inboxApi.listApplications({ cityList: true, top: 1 }) const res = await inboxApi.listApplications({ cityList: true, top: 1 })
return Array.isArray(res?.cities) ? res.cities : [] return {
cities: Array.isArray(res?.cities) ? res.cities : [],
sources: Array.isArray(res?.sources) ? res.sources : [],
}
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
const cities = citiesQuery.data ?? [] const cities = Array.isArray(citiesQuery.data)
? citiesQuery.data
: (citiesQuery.data?.cities ?? [])
const sources = Array.isArray(citiesQuery.data)
? []
: (citiesQuery.data?.sources ?? [])
const applicationsQuery = useQuery({ const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams), queryKey: qk.mailbox.applications(listParams),
@ -1172,8 +1242,10 @@ export default function Inbox() {
sheet: formCountsSheet, sheet: formCountsSheet,
...(search ? { search } : {}), ...(search ? { search } : {}),
...(city ? { city } : {}), ...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
...linkFilters, ...linkFilters,
}), [formCountsSheet, search, city, linkFilters]) }), [formCountsSheet, search, city, source, assignedParams, linkFilters])
const formCountsQuery = useQuery({ const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts(formCountsParams), queryKey: qk.mailbox.formCounts(formCountsParams),
queryFn: async () => { queryFn: async () => {
@ -1288,7 +1360,7 @@ export default function Inbox() {
const searchTotal = isAllChannel const searchTotal = isAllChannel
? (Number(applicationsQuery.data?.total ?? 0) + Number(formQuery.data?.total ?? 0)) ? (Number(applicationsQuery.data?.total ?? 0) + Number(formQuery.data?.total ?? 0))
: (activeQuery.data?.total ?? 0) : (activeQuery.data?.total ?? 0)
const total = (search || city) const total = (search || city || source || inboxFilters.assigned)
? searchTotal ? searchTotal
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0))) : (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing // 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
@ -1336,6 +1408,7 @@ export default function Inbox() {
...selectedRow, ...selectedRow,
...(detailQuery.data ?? {}), ...(detailQuery.data ?? {}),
received: selectedRow?.received ?? detailQuery.data?.received ?? null, received: selectedRow?.received ?? detailQuery.data?.received ?? null,
atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null,
} }
: null : null
@ -1385,7 +1458,12 @@ export default function Inbox() {
setQ('') setQ('')
setSearch('') // clear the committed term too, or the new channel's first setSearch('') // clear the committed term too, or the new channel's first
// fetch carries the old channel's search for 300ms // fetch carries the old channel's search for 300ms
setInboxFilters((f) => ({ ...EMPTY_INBOX_FILTERS, location: f.location })) setInboxFilters((f) => ({
...EMPTY_INBOX_FILTERS,
location: f.location,
source: f.source,
assigned: f.assigned,
}))
// Unread is email-only; leave it behind when opening Sheet Forms or All. // Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && tab === 'Unread') setTab('All Applications') if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear() selection.clear()
@ -1407,7 +1485,13 @@ export default function Inbox() {
if (SERVER_SCOPED_TABS.has(tab)) { if (SERVER_SCOPED_TABS.has(tab)) {
setReadAll.mutate({ setReadAll.mutate({
read, read,
filter: { ...tabFilter, ...(search ? { search } : {}) }, filter: {
...tabFilter,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
},
// sheet rows carry no mailbox read state email rows only // sheet rows carry no mailbox read state email rows only
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id), ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
}) })
@ -1701,6 +1785,7 @@ export default function Inbox() {
<InboxFilters <InboxFilters
filters={inboxFilters} filters={inboxFilters}
cities={cities} cities={cities}
sources={sources}
showLinkFilters={isForms} showLinkFilters={isForms}
active={activeInboxFilters} active={activeInboxFilters}
open={showInboxFilters} open={showInboxFilters}
@ -2467,7 +2552,7 @@ function ApplicationDetail({
{i.atsScore != null && ( {i.atsScore != null && (
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}> <div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div> <div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{Math.round(i.atsScore)}</div></div>
</div> </div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div> <div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div> </div>