From 22b2a7323fe4fc23a4afc89e386add750614fa08 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Thu, 3 Sep 2026 19:53:23 +0500 Subject: [PATCH] Fix inbox loading stall Co-authored-by: Cursor --- backend/inbox/models.py | 17 +++++++++++++++-- backend/inbox/serializers.py | 31 +++++++++++++++++++++++-------- backend/inbox/views.py | 8 ++++---- frontend/src/screens/Inbox.jsx | 17 ++++++++++------- 4 files changed, 52 insertions(+), 21 deletions(-) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 8875179..0aba27e 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -13,7 +13,7 @@ from sqlalchemy import Column, DateTime, case, func, or_, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import selectinload +from sqlalchemy.orm import defer, selectinload from sqlmodel import Field, Relationship, SQLModel, select, true from job.candidate.models import Activity, Feedback, Interviews @@ -821,7 +821,7 @@ 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 + 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 ): statement = cls._apply_filters( @@ -835,6 +835,19 @@ class Inbox_Messages(SQLModel, table=True): if top is not None: statement = statement.limit(top) + # List screens never render these. Loading them for every row is what + # makes GET /inbox/all-applications hang on a full mailbox: each value + # is TOASTed (Graph payload, extracted CV, HTML body). Do not read + # them after this — a deferred access lazy-loads per row. + if light: + statement = statement.options( + defer(cls.full_email_response), + defer(cls.resume_text), + defer(cls.message_body), + defer(cls.message_reply), + defer(cls.match_reasoning), + ) + result = await session.execute(statement) return result.scalars().all() diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 84250d2..61c43d7 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -13,9 +13,19 @@ _RESUME_STATUS = { } -def _sender_name(message: Inbox_Messages) -> str: - """Graph's display name when the payload carries one, else the raw address.""" - sender_name = message.message_from +def _sender_name(message: Inbox_Messages, *, light: bool = False) -> str: + """Graph's display name when the payload carries one, else the raw address. + + `light` must not touch `full_email_response` — the list query defers that + column, and reading it here would lazy-load the whole Graph payload per row. + """ + raw = message.message_from or "" + if light: + # "Jane Doe " → Jane Doe; otherwise the address as stored. + if "<" in raw and raw.endswith(">"): + return raw.split("<", 1)[0].strip() or raw + return raw + sender_name = raw full = message.full_email_response if isinstance(full, dict): from_block = full.get("from") @@ -81,7 +91,7 @@ _PROCESSING_LABEL = { } -def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict: +def serialize_application(message: Inbox_Messages, *, linkedin_url=None, light: bool = False) -> dict: """inbox_messages row -> the shape the #inbox All Applications tab renders. `position` is the mail subject and `source` is the To address, which is where @@ -92,15 +102,18 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict processed / rejected from the processing_state column so those writes are visible; the default `unread` state still follows message_read so existing rows keep Read/Unread until someone PATCHes a later state. + + `light=True` is the list path: skip resume_text / match_reasoning / + Graph-payload name lookup so we never touch columns the list query defers. """ state = (message.processing_state or "").strip().lower() if state in ("imported", "processed", "rejected"): processing = _PROCESSING_LABEL[state] else: processing = "Read" if message.message_read else "Unread" - return { + payload = { "id": str(message.id), - "name": _sender_name(message), + "name": _sender_name(message, light=light), "email": message.message_from, "position": message.message_subject, "source": message.message_to, @@ -114,11 +127,9 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict "file_path": message.file_path, "linkedin_slug": message.linkedin_slug or None, "linkedin_url": linkedin_url or None, - "resume_text": message.resume_text, "suggested_job_post_ids": list(message.suggested_job_post_ids or []), "assigned_job_post_id": str(message.assigned_job_post_id) if message.assigned_job_post_id else None, "match_summary": message.match_summary, - "match_reasoning": message.match_reasoning, "match_status": message.match_status, "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, @@ -133,6 +144,10 @@ def serialize_application(message: Inbox_Messages, *, linkedin_url=None) -> dict "processing_state": message.processing_state, "source_channel_id": message.source_channel_id, } + if not light: + payload["resume_text"] = message.resume_text + payload["match_reasoning"] = message.match_reasoning + return payload def serialize_triage(row: Inbox_Message_Triage) -> dict: diff --git a/backend/inbox/views.py b/backend/inbox/views.py index c569990..c9122d2 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -275,13 +275,13 @@ class Email: 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): 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) + 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) 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) + 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) 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) + 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) urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages]) - return [serialize_application(m,linkedin_url=urls.get(m.id)) for m in messages] + return [serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages] async def get_application_by_id(self,record_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 048c971..48649f7 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -444,11 +444,8 @@ async function fetchApplications(params) { filePath: row.file_path || '', linkedinSlug: row.linkedin_slug || '', linkedinUrl: row.linkedin_url || '', - // resume_text is deliberately DROPPED here. serialize_application ships - // the whole extracted CV on every row, and the queue renders none of - // it — but the cache would then hold a thousand full resumes for the - // hour that LIST_CACHE keeps a page alive. The detail query fetches it - // for the one row actually open, which is the only place it is read. + // resume_text is not on the list payload (serialize_application + // light=True). The detail query fetches it for the open row. atsScore: row.ats_score, phone: row.phone, experience: row.experience, @@ -730,7 +727,7 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, > {n > 0 ? `${n} selected` - : `${total}${unreadCount ? ` · ${unreadCount}` : ''}`} + : `${total}${unreadCount ? ` · ${unreadCount} unread` : ''}`} {/* Same switch as the Per-page "All" entry below, surfaced at the top of the queue where the eye actually is. */} @@ -977,7 +974,13 @@ export default function Inbox() { const activeQuery = isAllChannel ? { - isPending: applicationsQuery.isPending || formQuery.isPending, + // Pending only while NOTHING has arrived. The old OR kept the + // combined query "pending" until the slower source finished, which + // painted six skeletons on top of the email rows that were already + // on screen — the list looked broken on every visit. + isPending: + (applicationsQuery.isPending || formQuery.isPending) + && !(mergedRows && mergedRows.length), // one healthy source still renders; error only when both are down isError: applicationsQuery.isError && formQuery.isError, isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess,