diff --git a/backend/g_sheet/views.py b/backend/g_sheet/views.py index 9e587cf..423b768 100644 --- a/backend/g_sheet/views.py +++ b/backend/g_sheet/views.py @@ -340,23 +340,23 @@ class SheetFormData(Sheet): """FormData DB mirror — query / delete only (no Google client).""" async def _hydrate_job_posts(self,items): - """Attach suggested job_posts, assigned_job_post, and per-job ATS scores. + """Attach suggested job titles, assigned_job_post, and per-job ATS scores. Preferred source is suggested_job_post_ids (ILIKE matches stored on import). Legacy rows without that list still title-match. ATS is one - current score per (form, job). + current score per (form, job). Full JD loads when a card is expanded. """ if not items: return items from g_sheet.scoring import serialize_form_ats from inbox.models import AtsResults from job.job_post.models import JobPosts - from job.job_post.serializers import serialize_job_post + from job.job_post.serializers import serialize_job_post_title session=self._require_session() def _job_payload(post): - payload=serialize_job_post(post) + payload=serialize_job_post_title(post) if post.is_deleted or not post.is_active: payload={**payload,"unavailable":True} return payload @@ -374,7 +374,7 @@ class SheetFormData(Sheet): wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids])) by_id={} if wanted: - for post in await JobPosts.get_by_ids(session,wanted,active_only=False): + for post in await JobPosts.titles_by_ids(session,wanted,active_only=False): by_id[str(post.id)]=_job_payload(post) titles=[(item.get("position_applied_for") or "").strip() for item in items] diff --git a/backend/inbox/plugins.py b/backend/inbox/plugins.py index 9e688cd..88ae31d 100644 --- a/backend/inbox/plugins.py +++ b/backend/inbox/plugins.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import base64 import logging import os import uuid @@ -138,6 +137,7 @@ def load_file_bytes(path_or_url: str) -> bytes | None: def load_message_files(message:Inbox_Messages) -> list[dict]: + """Filename + public URL only. Open-resume uses the S3 link; do not pull bytes.""" if not message.file_path: return [] names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()] @@ -147,22 +147,6 @@ def load_message_files(message:Inbox_Messages) -> list[dict]: entry={"file_name":name or "resume.pdf","url":None,"content_base64":None,"size":0} if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"): entry["url"]=path_str - raw=load_file_bytes(path_str) - if raw is not None: - entry["content_base64"]=base64.b64encode(raw).decode("ascii") - entry["size"]=len(raw) - files.append(entry) - continue - path=resolve_attachment_path(path_str) - if not path.is_file(): - continue - try: - raw=path.read_bytes() - except OSError: - continue - entry["file_name"]=path.name - entry["content_base64"]=base64.b64encode(raw).decode("ascii") - entry["size"]=len(raw) files.append(entry) return files diff --git a/backend/inbox/views.py b/backend/inbox/views.py index b2e35ba..1a0df7f 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -256,23 +256,7 @@ class Email: item["files"]=files from job.candidate.views import CandidateView cv=CandidateView(session=self.session) - suggested=[] - for job_id in item.get("suggested_job_post_ids") or []: - jp=await cv.get_job_post_by_id(record_id=job_id) - if jp: - if jp.get("is_deleted") or not jp.get("is_active"): - suggested.append({**jp,"unavailable":True}) - else: - suggested.append(jp) - else: - suggested.append({"id":str(job_id),"unavailable":True}) - item["suggested_job_posts"]=suggested - assigned_id=item.get("assigned_job_post_id") - if assigned_id: - item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id) - else: - item["assigned_job_post"]=None - items=await self._paint_inbox_ats([item]) + items=await self._attach_job_posts([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,source=None): @@ -289,10 +273,11 @@ class Email: 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. + """List payload needs assigned + suggested titles — export reads names. - 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. + Detail hydrates one row. Full JD (location, requirements) loads when + the recruiter expands a card. Assigned job and Suggested jobs in the + Inbox .xlsx stay as titles. """ ids=[] for item in items: @@ -305,9 +290,9 @@ class Email: 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) + from job.job_post.serializers import serialize_job_post_title + for post in await JobPosts.titles_by_ids(self.session,ids,active_only=False): + payload=serialize_job_post_title(post) if post.is_deleted or not post.is_active: payload={**payload,"unavailable":True} by_id[str(post.id)]=payload diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index c2982ae..fafa934 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import aliased +from sqlalchemy.orm import aliased, load_only from sqlmodel import Field, Relationship, SQLModel, select from job.job_post.enums import RequisitionStatus @@ -127,6 +127,31 @@ class JobPosts(SQLModel, table=True): # Preserve request order so suggestion ranks stay stable. return [by_id[str(u)] for u in uids if str(u) in by_id] + @classmethod + async def titles_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = False): + """id + title + status only — inbox suggestion rail before a card expands. + + Skips description / post_text TOAST columns. Rank order matches `ids`. + """ + uids = [] + for raw in ids or []: + uid = cls._as_uuid(raw) + if uid is not None: + uids.append(uid) + if not uids: + return [] + statement = ( + select(cls) + .options(load_only(cls.id, cls.title, cls.status, cls.is_active, cls.is_deleted)) + .where(cls.id.in_(uids)) + ) + if active_only: + statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712 + result = await session.execute(statement) + rows = list(result.scalars().all()) + by_id = {str(r.id): r for r in rows} + return [by_id[str(u)] for u in uids if str(u) in by_id] + @classmethod async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False): """Match job posts whose title equals any of `titles` (trim + case-insensitive). diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 5cf3ef2..608f785 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -8,6 +8,17 @@ def _status_label(value): return parsed.label if parsed else value +def serialize_job_post_title(row) -> dict: + """Inbox suggestion rail — title only until the recruiter expands the card.""" + return { + "id": str(row.id), + "title": row.title, + "status": row.status, + "is_active": row.is_active, + "is_deleted": row.is_deleted, + } + + def serialize_job_post(row) -> dict: return { "id": str(row.id), diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index 0b8e962..ff2e8b6 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -78,6 +78,7 @@ export const qk = { jobPosts: { all: () => ['jobPosts'], list: (p = {}) => ['jobPosts', 'list', p], + detail: (id) => ['jobPosts', 'detail', id], departments: (p = {}) => ['jobPosts', 'departments', p], }, jobs: { diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 09d52bc..4a2631c 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -71,22 +71,39 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS } /** * Last 10 opened applicant details. The queue list is a light row; GET-by-id * is the body, suggested roles, resume text. Re-opening one of these should - * not pay that trip again. Cap is LRU — the 11th open drops the oldest from - * the query cache. Mutations that write THIS row still invalidate its key. + * not pay that trip again for 15 minutes. Cap is LRU — the 11th open drops + * the oldest. After the TTL the snapshot is dropped so the next open refetches. + * Mutations that write THIS row still invalidate its key. */ const OPENED_DETAIL_CAP = 10 +const OPENED_DETAIL_TTL_MS = 15 * 60_000 const openedDetailLru = [] function detailQueryKey(kind, id) { return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id) } +function pruneOpenedDetailLru(qc, keepSig) { + const cutoff = Date.now() - OPENED_DETAIL_TTL_MS + const next = [] + for (const x of openedDetailLru) { + if (x.at > cutoff || x.sig === keepSig) { + next.push(x) + continue + } + qc.removeQueries({ queryKey: x.key }) + } + openedDetailLru.length = 0 + openedDetailLru.push(...next) +} + function rememberOpenedDetail(qc, kind, id) { if (!id) return const sig = `${kind}:${id}` const key = detailQueryKey(kind, id) + pruneOpenedDetailLru(qc, sig) const next = openedDetailLru.filter((x) => x.sig !== sig) - next.push({ sig, kind, id, key }) + next.push({ sig, kind, id, key, at: Date.now() }) while (next.length > OPENED_DETAIL_CAP) { const dropped = next.shift() if (dropped && dropped.sig !== sig) { @@ -98,9 +115,9 @@ function rememberOpenedDetail(qc, kind, id) { } const DETAIL_CACHE = { - staleTime: Infinity, - gcTime: INBOX_GC_MS, - refetchOnMount: false, + staleTime: OPENED_DETAIL_TTL_MS, + gcTime: OPENED_DETAIL_TTL_MS, + refetchOnMount: true, refetchOnWindowFocus: false, refetchOnReconnect: false, } @@ -1428,6 +1445,7 @@ export default function Inbox() { ...DETAIL_CACHE, }) useEffect(() => { + pruneOpenedDetailLru(qc, selectedId ? `${selectedKind}:${selectedId}` : null) if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return rememberOpenedDetail(qc, selectedKind, selectedId) }, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data]) @@ -2381,6 +2399,7 @@ function FormApplicantDetail({ badge={`Match #${rank}`} selected={String(selection) === String(post.id)} onSelect={(id) => setSelection(String(id))} + lazy /> ))} {manualPost && ( @@ -2390,6 +2409,7 @@ function FormApplicantDetail({ manual selected={String(selection) === String(manualPost.id)} onSelect={(id) => setSelection(String(id))} + lazy /> )} @@ -2788,6 +2808,7 @@ function ApplicationDetail({ selected={String(selection) === String(post.id)} onSelect={(id) => setSelection(String(id))} resumeText={resumeText} + lazy /> )) )} @@ -2799,6 +2820,7 @@ function ApplicationDetail({ selected={String(selection) === String(manualPost.id)} onSelect={(id) => setSelection(String(id))} resumeText={resumeText} + lazy /> )} + )} {tag}
{title}
{unavailable ? ( @@ -77,10 +114,16 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba {post?.overall_score != null && } {selected && } - {meta &&
{meta}
} - {!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && ( + {showBody && detailQuery.isPending && lazy && !hasBody && ( +
Loading role…
+ )} + {showBody && detailQuery.isError && lazy && ( +
{friendlyAuthError(detailQuery.error, 'Could not load this role.')}
+ )} + {showBody && meta &&
{meta}
} + {showBody && !unavailable && Array.isArray(body.requirements) && body.requirements.length > 0 && (
- {post.requirements.slice(0, 8).map((req, i) => { + {body.requirements.slice(0, 8).map((req, i) => { const label = reqLabel(req) if (!label) return null const hit = reqInResume(req, resumeText)