diff --git a/backend/inbox/app.py b/backend/inbox/app.py index e4953a0..df08030 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -25,6 +25,30 @@ class DuplicateBody(BaseModel): is_duplicate: bool +class ReadBody(BaseModel): + read: bool = True + + +class BulkReadBody(BaseModel): + record_ids: list[str] + read: bool = True + + +class ReadAllBody(BaseModel): + """The caller's CURRENT list filter, echoed back so the update narrows the same way. + + Every field defaults to the same "no filter" value the list endpoint uses, so an + empty body means "the All Applications tab" — exactly what GET + /inbox/all-applications returns with no query params. + """ + + read: bool = True + search: str | None = None + isread: bool = True + application_status: Candidate_application_Status = Candidate_application_Status.CLOSED + assigned: bool | None = None + + class TriageOverrideBody(BaseModel): is_application: bool @@ -145,12 +169,15 @@ async def assign_job_post( @router.post("/inbox/{record_id}/read") async def mark_inbox_read( record_id: str, + payload: ReadBody | None = None, current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), session: AsyncSession = Depends(get_session), ): + """Flip one row. The body is OPTIONAL and defaults to read=true, so the original + bodyless POST this route shipped with keeps working unchanged.""" try: service=Email(session=session) - data=await service.mark_read(record_id) + data=await service.mark_read(record_id,payload.read if payload else True) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: raise @@ -158,6 +185,47 @@ async def mark_inbox_read( raise HTTPException(status_code=500,detail=str(e)) +@router.patch("/inbox/read") +async def bulk_mark_inbox_read( + payload: BulkReadBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + """Selected rows -> read/unread. Single segment after /inbox, so it never collides + with the two-segment /inbox/{record_id}/read above.""" + try: + service=Email(session=session) + data=await service.set_read_bulk(payload.record_ids,payload.read) + return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/inbox/read-all") +async def mark_all_inbox_read( + payload: ReadAllBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + """Every row matching the caller's current list filter -> read/unread.""" + try: + service=Email(session=session) + data=await service.set_read_all( + payload.read, + search=payload.search, + isread=payload.isread, + application_status=payload.application_status, + assigned=payload.assigned, + ) + return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/inbox/{record_id}/read-status") async def get_inbox_read_status( record_id: str, diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 44d2832..ea1d579 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -322,6 +322,11 @@ class Inbox_Messages(SQLModel, table=True): message_cc: str | None = Field(default=None) message_bcc: str | None = Field(default=None) message_read: bool = Field(default=False) + # Stamped whenever a human flips read state from the app (single row, bulk, or + # whole view). apply_read_status skips these rows: nothing pushes local state + # back to Outlook, so without the stamp the every-minute sync_read_status sweep + # would silently re-read a mail the recruiter deliberately marked unread. + read_overridden_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) attachment: bool = Field(default=False) message_reply: str | None = Field(default=None) file_name: str | None = Field(default=None) @@ -568,29 +573,44 @@ 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 + def _apply_filters( + cls, statement, search: str | None=None, isread: bool=True, + application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, + assigned: bool | None=None, ): - statement = select(cls).order_by(cls.message_received_time.desc()) + """The one WHERE chain shared by the list, the count and the bulk read UPDATE. + + Works on a Select or an Update — both expose .where() — which is the whole + point: "mark all read in this view" must narrow on exactly the predicates the + list narrowed on. A scope filter that drifts from the list filter silently + touches rows the user never saw, and there is no undo for that. + """ if search: statement = statement.where(cls._search_filter(search)) - if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: statement = statement.where(cls.application_status==application_status) - if assigned is True: statement = statement.where(cls.assigned_job_post_id.is_not(None)) elif assigned is False: statement = statement.where(cls.assigned_job_post_id.is_(None)) + if isread==False: + statement = statement.where(cls.message_read==False) + return statement + @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 + ): + statement = cls._apply_filters( + select(cls).order_by(cls.message_received_time.desc()), + search, isread, application_status, assigned, + ) if skip: statement = statement.offset(skip) if top is not None: statement = statement.limit(top) - if isread==False: - statement = statement.where(cls.message_read==False) result = await session.execute(statement) return result.scalars().all() @@ -635,17 +655,10 @@ class Inbox_Messages(SQLModel, table=True): @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): - statement = select(func.count()).select_from(cls) - if search: - statement = statement.where(cls._search_filter(search)) - if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED: - statement = statement.where(cls.application_status==application_status) - if assigned is True: - statement = statement.where(cls.assigned_job_post_id.is_not(None)) - elif assigned is False: - statement = statement.where(cls.assigned_job_post_id.is_(None)) - if isread==False: - statement = statement.where(cls.message_read==False) + statement = cls._apply_filters( + select(func.count()).select_from(cls), + search, isread, application_status, assigned, + ) result = await session.execute(statement) return result.scalar_one() @@ -659,6 +672,12 @@ class Inbox_Messages(SQLModel, table=True): every-minute sync_read_status sweep would otherwise revert a mail the user just opened. Cost of the latch: un-reading a mail in Outlook no longer propagates here. + + Rows with read_overridden_at set are excluded outright. The latch alone is not + enough once the UI can mark UNREAD: a mail that is read in Outlook keeps being + reported isRead=true, so the next sweep would undo the recruiter's click within + the minute. A human decision on this row wins permanently; the only rows + excluded are ones somebody already decided about. """ if not changes: return 0 @@ -666,7 +685,9 @@ class Inbox_Messages(SQLModel, table=True): if not read_ids: return 0 result=await session.execute( - update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True) + update(cls) + .where(cls.message_id.in_(read_ids),cls.read_overridden_at.is_(None)) + .values(message_read=True) ) await session.commit() return result.rowcount or 0 @@ -698,16 +719,63 @@ class Inbox_Messages(SQLModel, table=True): return {row for (row,) in result.all() if row} @classmethod - async def mark_message_read(cls, session: AsyncSession, record_id): + async def mark_message_read(cls, session: AsyncSession, record_id, read: bool=True): row=await cls.get_inbox_message_by_id(session,record_id) if not row: return None - row.message_read=True + row.message_read=bool(read) + row.read_overridden_at=_now() session.add(row) await session.commit() await session.refresh(row) return row + @classmethod + async def set_read_bulk(cls, session: AsyncSession, record_ids, read: bool) -> int: + """Flip read state for an explicit id list in ONE statement. Returns rows matched. + + Unparseable ids are dropped rather than raising: a stale row id in a selection + must not sink the other 49 the recruiter ticked. The caller compares `updated` + against `requested` to notice. + + No `message_read != read` predicate here — the caller wants to know how many of + its ids actually EXIST, which is what rowcount reports without it. + """ + uids=[] + for raw in record_ids or []: + try: + uids.append(uuid.UUID(str(raw))) + except (AttributeError, TypeError, ValueError): + continue + if not uids: + return 0 + result=await session.execute( + update(cls).where(cls.id.in_(uids)).values(message_read=bool(read),read_overridden_at=_now()) + ) + await session.commit() + return result.rowcount or 0 + + @classmethod + async def set_read_scope( + cls, session: AsyncSession, read: bool, search: str | None=None, isread: bool=True, + application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, + assigned: bool | None=None, + ) -> int: + """Mark every row matching a list filter. Returns rows actually CHANGED. + + The extra `message_read != read` predicate is what makes the count honest: the + recruiter is told "12 marked read", not "1,240 rows touched" on a mailbox that + 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) + statement=statement.where(cls.message_read!=bool(read)) + result=await session.execute( + statement.values(message_read=bool(read),read_overridden_at=_now()) + ) + await session.commit() + return result.rowcount or 0 + @classmethod async def count_processing(cls, session: AsyncSession): statement = select( diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 822ba6d..3a5bce8 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -32,6 +32,10 @@ from datetime import datetime,timezone logger=logging.getLogger("inbox.match") triage_logger=logging.getLogger("inbox.triage") +# One statement, one round trip — but an unbounded id list is still a client-supplied +# IN () of arbitrary size, so the batch is capped and the route answers 413. +MAX_BULK_READ_IDS=500 + class Email: def __init__(self,session:AsyncSession,token=None): @@ -349,12 +353,49 @@ class Email: logger.warning("could not queue ats score for %s: %s",record_id,exc) return await self.get_inbox_message_by_id(record_id) - async def mark_read(self,record_id): - message=await Inbox_Messages.mark_message_read(self.session,record_id) + async def mark_read(self,record_id,read=True): + message=await Inbox_Messages.mark_message_read(self.session,record_id,read) if not message: raise HTTPException(status_code=404,detail="Message not found") return serialize_message(message) + async def set_read_bulk(self,record_ids,read): + """Flip read state for a hand-picked selection. + + Returns counts, never rows: a 500-id selection would otherwise serialize 500 + full messages back at a client that only needs to know it worked. + + `updated` < `requested` means some ids no longer exist — a stale selection + against a list that moved. That is reported, not raised, because the rows that + DID exist were already committed. + """ + ids=[str(r).strip() for r in (record_ids or []) if str(r or "").strip()] + if not ids: + raise HTTPException(status_code=422,detail="record_ids must contain at least one id") + if len(ids)>MAX_BULK_READ_IDS: + raise HTTPException(status_code=413, + detail=f"At most {MAX_BULK_READ_IDS} ids per request") + updated=await Inbox_Messages.set_read_bulk(self.session,ids,read) + logger.info("bulk read: requested=%s updated=%s read=%s",len(ids),updated,bool(read)) + return {"requested":len(ids),"updated":updated,"read":bool(read)} + + async def set_read_all(self,read,search=None,isread:bool=True, + application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, + assigned=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 + button says "mark all read in this view" and the WHERE chain is literally the + list's own (Inbox_Messages._apply_filters), so the two cannot drift. + """ + updated=await Inbox_Messages.set_read_scope( + self.session,read,search=search,isread=isread, + application_status=application_status,assigned=assigned, + ) + logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s", + updated,bool(read),isread,assigned,getattr(application_status,"value",application_status)) + return {"updated":updated,"read":bool(read)} + async def refresh_read_status(self,record_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) if not message: diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 1e0e414..7366f23 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -56,9 +56,57 @@ export function syncMailbox({ token, top, skip } = {}) { return request('/email/fetch', { params: { token, top, skip } }) } -/** Marks one persisted inbox row read (local DB only). */ -export function markRead(recordId) { - return request(`/inbox/${recordId}/read`, { method: 'POST' }) +/** + * Flips one persisted inbox row read/unread (local DB only — nothing is pushed + * back to Outlook). The body is optional server-side and defaults to read=true. + */ +export function markRead(recordId, read = true) { + return request(`/inbox/${recordId}/read`, { method: 'POST', body: { read } }) +} + +/** + * Flips a hand-picked selection in one statement. Requires inbox.edit. + * + * Capped at 500 ids server-side (MAX_BULK_READ_IDS in backend/inbox/views.py), + * which answers 413 — callers with a longer list chunk it. + * + * Resolves to `{requested, updated, read}`. `updated < requested` means some ids + * no longer exist, not that the call failed: the rows that did exist committed. + */ +export function bulkSetRead(recordIds, read) { + return request('/inbox/read', { + method: 'PATCH', + body: { record_ids: recordIds, read }, + }) +} + +/** + * Flips EVERY row matching a list filter — the "mark all in this view" button. + * + * The filter params are deliberately the same ones listApplications takes, and + * the server runs them through the same WHERE builder the list uses + * (Inbox_Messages._apply_filters). Omit them all and the scope is the whole + * mailbox, which is exactly what the All Applications tab shows. + * + * `search` is NOT the Inbox screen's search box: that filters client-side on + * name/position/source, while the server matches subject/from/body. Passing one + * for the other would mark rows the user never saw — the screen sends the + * visible ids to bulkSetRead instead whenever its search box is non-empty. + * + * 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 } = {}) { + return request('/inbox/read-all', { + method: 'PATCH', + body: { + read, + search, + isread, + application_status: applicationStatus, + assigned, + }, + }) } /** Assign (or clear with null) the job post for one application. Requires inbox.edit. */ diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index f8368ac..22d626b 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -9,7 +9,7 @@ now, which is the structural fix. ============================================================ */ -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -17,7 +17,9 @@ import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' +import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -44,6 +46,17 @@ const TAB_FILTERS = { Unread: { isread: false }, } +/** + * Tabs whose visible set is EXACTLY what the server returns for TAB_FILTERS[tab]. + * + * Only these may use the scope endpoint for "mark all". Processed / Rejected / + * Duplicates narrow client-side over rows the server already handed back in full, + * so a scope call from one of those carries no such predicate and would mark the + * entire mailbox — rows the user never saw, with no undo. Those tabs send the + * visible ids instead. + */ +const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread']) + /** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ const NOW = new Date('2026-07-09T20:00') @@ -114,14 +127,15 @@ const RESUME_STATUS = { failed: 'Failed', dlq: 'Failed', skipped: 'Pending', } +const SHORTLIST_JOB_WARNING = 'Choose one of the suggested jobs above to add this candidate to the shortlist.' + /** * GET /inbox/fetch?record_id= -> the detail behind one application row. * * Returns serialize_message, a different shape from serialize_application, so it * is remapped onto the row shape here and OVERLAID on the list row rather than - * replacing it: serialize_message carries the body and the real decoded - * attachments, but omits resume_text, so the list row keeps supplying that. - * suggested_job_post_ids is dropped, same as everywhere else on this page. + * replacing it: serialize_message carries the body, decoded attachments, and + * the hydrated suggested_job_posts / assigned_job_post the role picker needs. */ async function fetchMessageDetail(recordId) { const res = await inboxApi.getMessage(recordId) @@ -154,6 +168,11 @@ async function fetchMessageDetail(recordId) { matchReasoning: row.match_reasoning || '', matchError: row.match_error || '', matchedAt: parseDate(row.matched_at), + resumeText: row.resume_text || '', + suggestedIds: (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, } } @@ -190,6 +209,8 @@ async function fetchApplications(params) { experience: row.experience, recruiter: row.recruiter, duplicate: Boolean(row.duplicate), + suggestedIds: (row.suggested_job_post_ids || []).map(String), + assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, } }) } @@ -197,46 +218,273 @@ async function fetchApplications(params) { // Shared with the sidebar badge through qk.mailbox.counts — see inboxApi.fetchCounts. const fetchInboxCounts = inboxApi.fetchCounts +/** 500 is MAX_BULK_READ_IDS in backend/inbox/views.py; a longer list gets a 413. */ +const BULK_READ_CHUNK = 500 + +async function setReadChunked(ids, read) { + let updated = 0 + for (let i = 0; i < ids.length; i += BULK_READ_CHUNK) { + // Sequential on purpose. Each chunk is one UPDATE over a few hundred rows of + // the same table; firing them together only puts them in each other's way. + // eslint-disable-next-line no-await-in-loop + const res = await inboxApi.bulkSetRead(ids.slice(i, i + BULK_READ_CHUNK), read) + updated += res?.data?.updated ?? 0 + } + return { updated, requested: ids.length } +} + /** - * POST /inbox/{record_id}/read — flips message_read false -> true for one row. - * - * Optimistic, so the row un-bolds on click instead of after the round trip, and - * rolls back if the server rejects. Both mailbox caches hold {id, unread} rows, - * so one setQueriesData over qk.mailbox.all() covers the Email tab and the - * application tabs at once; `processing` is derived from the same column, so it - * moves with it. - * - * NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, - * so a view-only user gets a 403 here and the row snaps back to unread. + * Rewrites ONE cache entry for a read-state flip: a row list, or the + * single-message detail object. Anything else (the counts object) passes through + * untouched — it has no `id`, and its delta is applied separately. */ -function useMarkRead(toast) { +function patchReadState(data, idSet, read) { + const patchRow = (r) => (idSet.has(r.id) + ? { + ...r, + unread: !read, + // `processing` also carries Imported / Processed / Rejected, which are a + // different column. Only the read-derived pair moves with this one. + processing: r.processing === 'Unread' || r.processing === 'Read' + ? (read ? 'Read' : 'Unread') + : r.processing, + } + : r) + if (Array.isArray(data)) return data.map(patchRow) + if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data) + return data +} + +/** + * PATCH /inbox/read — flips message_read for one row or for two hundred. + * + * The single-row route (POST /inbox/{id}/read) still exists, but every flip goes + * through the bulk one so there is exactly ONE optimistic patch to reason about. + * + * Optimistic, so rows un-bold on click instead of after the round trip, and roll + * back if the server rejects. Both mailbox caches hold {id, unread} rows, so one + * setQueriesData over qk.mailbox.all() covers the Email tab and the application + * tabs at once; `processing` is derived from the same column, so it moves with it. + * + * NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, so + * a view-only user gets a 403 here and the rows snap back. The bulk bars disable + * themselves for those users; click-to-read cannot, so it still relies on rollback. + */ +function useSetRead(toast) { const qc = useQueryClient() return useMutation({ - mutationFn: (recordId) => inboxApi.markRead(recordId), - onMutate: async (recordId) => { + mutationFn: ({ ids, read }) => setReadChunked(ids, read), + onMutate: async ({ ids, read }) => { await qc.cancelQueries({ queryKey: qk.mailbox.all() }) const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() }) - qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => ( - Array.isArray(rows) - ? rows.map((r) => (r.id === recordId - ? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing } - : r)) - : rows - )) + const idSet = new Set(ids) + + // Which rows actually FLIP, counted BEFORE the patch: the counts object is a + // bag of totals with no per-row detail to derive the delta from afterwards. + // A Set, because the same id appears in several cached lists at once. + const flipping = new Set() + for (const [, data] of previous) { + if (!Array.isArray(data)) continue + for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id) + } + + qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read)) + // The Unread tab badge and the sidebar badge share this entry. Without the + // delta they sit stale until the refetch lands — invisible for one row, + // glaring for two hundred. `previous` already covers it for rollback, + // because ['mailbox'] is a prefix of ['mailbox','counts']. + qc.setQueryData(qk.mailbox.counts(), (c) => (c + ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) } + : c)) return { previous } }, - onError: (err, _recordId, ctx) => { + onError: (err, _vars, ctx) => { for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) - toast(friendlyAuthError(err, 'Could not mark as read.'), 'error') + toast(friendlyAuthError(err, 'Could not update read state.'), 'error') }, onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), }) } +/** + * PATCH /inbox/read-all — every row matching a server-side list filter. + * + * Deliberately NOT optimistic: the scope is a WHERE clause, so the client cannot + * know which rows it hit until the count comes back. It reports that count rather + * than guessing, which is also the honest answer when the view was already read. + */ +function useSetReadAll(toast) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }), + onSuccess: (res, { read }) => { + const n = res?.data?.updated ?? 0 + toast( + n === 0 + ? `Nothing to mark ${read ? 'read' : 'unread'} here` + : `${n} ${n === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, + 'success', + ) + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not update read state.'), 'error'), + onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), + }) +} + +/** + * Multi-select over a row list: the Set, the toggles, and the pruning. + * + * The pruning is not optional. Rows leave a list under their own steam — the + * Unread tab drops a row the instant it is marked read — so a Set left alone + * keeps counting ids that are no longer on screen, and "Mark 12 read" quietly + * acts on 9 of them. + */ +function useRowSelection(rows) { + const [selectedIds, setSelectedIds] = useState(() => new Set()) + const visibleIds = useMemo(() => rows.map((r) => r.id), [rows]) + + useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev + const visible = new Set(visibleIds) + const next = new Set() + for (const id of prev) if (visible.has(id)) next.add(id) + // Same size means nothing was pruned. Returning `prev` keeps the Set + // identity stable, so this effect cannot retrigger itself. + return next.size === prev.size ? prev : next + }) + }, [visibleIds]) + + const toggle = useCallback((id) => setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }), []) + + const clear = useCallback(() => setSelectedIds((prev) => (prev.size ? new Set() : prev)), []) + + const toggleAll = useCallback(() => setSelectedIds((prev) => ( + prev.size >= visibleIds.length ? new Set() : new Set(visibleIds) + )), [visibleIds]) + + return { + selectedIds, + toggle, + toggleAll, + clear, + allSelected: visibleIds.length > 0 && selectedIds.size === visibleIds.length, + } +} + +/** + * The row tick. stopPropagation is load-bearing: without it a tick also runs the + * row's onClick, which opens the detail pane AND auto-marks it read — instantly + * undoing the "mark unread" the user is selecting rows for. + */ +function RowCheck({ checked, onToggle, label }) { + return ( + { e.stopPropagation(); onToggle() }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onToggle() + } + }} + > + + + ) +} + +/** + * Select-all plus the read/unread actions for one list. + * + * Every button names what it will hit — "Mark 12 read", or "Mark all read" with + * the scope spelled out in its tooltip — rather than a bare "Mark read" whose + * reach depends on selection state the user has to keep in their head. "All" is + * always the CURRENT view, never the whole mailbox from behind a filter. + */ +function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) { + const { selectedIds, toggleAll, clear, allSelected } = selection + const n = selectedIds.size + const disabled = !canEdit || busy + const tip = !canEdit ? 'Requires inbox.edit' : undefined + const allTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}` + + return ( +
+ + + {n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`} + +
+ {n > 0 ? ( + <> + + + + + ) : ( + <> + + + + )} +
+
+ ) +} + export default function Inbox() { const { toast } = useToast() const navigate = useNavigate() const qc = useQueryClient() + const { can } = useAuth() + const canEdit = can('inbox.edit') const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('inbox') @@ -331,7 +579,53 @@ export default function Inbox() { ? { ...selectedRow, ...(detailQuery.data ?? {}) } : null - const markRead = useMarkRead(toast) + // Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can + // actually see, and the hook prunes the Set as that set changes. + const selection = useRowSelection(list) + const setRead = useSetRead(toast) + const setReadAll = useSetReadAll(toast) + + /** + * What "Mark all" means here, in words, for the button tooltip. Kept next to + * setReadEverything so the label and the scope cannot drift apart. + */ + const scopeLabel = q.trim() + ? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search` + : tab === 'All Applications' + ? 'every application' + : `the ${tab} tab` + + function setReadSelected(read) { + const ids = [...selection.selectedIds] + if (!ids.length) return + setRead.mutate({ ids, read }, { + onSuccess: () => { + toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') + selection.clear() + }, + }) + } + + function setReadEverything(read) { + // The scope endpoint may only be used where the server-side filter IS the + // view. The search box narrows client-side on name/position/source while the + // server's `search` matches subject/from/body, and three of the tabs narrow + // client-side entirely — handing either to a WHERE clause would mark rows + // that were never on screen. Those cases send the visible ids instead, which + // is exact and, since the list endpoint is unpaginated, complete. + if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) { + setReadAll.mutate({ read, filter: tabFilter }) + return + } + const ids = list.map((i) => i.id) + if (!ids.length) return + setRead.mutate({ ids, read }, { + onSuccess: () => { + toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') + selection.clear() + }, + }) + } const setState = useMutation({ mutationFn: ({ id, state }) => inboxApi.setProcessingState(id, state), @@ -360,7 +654,7 @@ export default function Inbox() { function select(id) { setSelectedId(id) const item = inbox.find((i) => i.id === id) - if (item?.unread) markRead.mutate(id) + if (item?.unread) setRead.mutate({ ids: [id], read: true }) } function importItem(item) { @@ -407,7 +701,7 @@ export default function Inbox() {
{ setTab(t); setSelectedId(null) }} + onChange={(t) => { setTab(t); setSelectedId(null); selection.clear() }} tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))} />
@@ -423,6 +717,17 @@ export default function Inbox() { setQ(e.target.value)} placeholder="Search applications…" /> + {applicationsQuery.isSuccess && ( + + )}
{applicationsQuery.isPending && ( Fetching applications from the server. @@ -441,6 +746,11 @@ export default function Inbox() { className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`} onClick={() => select(i.id)} > + selection.toggle(i.id)} + label={`Select ${i.name}`} + />
@@ -492,6 +802,8 @@ export default function Inbox() { item={selected} loading={detailQuery.isPending} busy={setState.isPending || markDuplicate.isPending} + canEdit={canEdit} + toast={toast} onPreview={() => setPreviewing(selected)} onImport={() => importItem(selected)} onMove={() => moveToPipeline(selected)} @@ -572,12 +884,91 @@ function orDash(value, suffix = '') { } function ApplicationDetail({ - item: i, loading, busy, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate, + item: i, loading, busy, canEdit, toast, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate, }) { - const navigate = useNavigate() + const qc = useQueryClient() const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match' const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)' + const [selection, setSelection] = useState(null) + const [manualPost, setManualPost] = useState(null) + const [showPicker, setShowPicker] = useState(false) + const [whyOpen, setWhyOpen] = useState(false) + + // Do not auto-pick the first suggestion: Shortlist stays locked until the + // recruiter actually chooses a card (or the row already has an assigned job). + useEffect(() => { + setManualPost(null) + setWhyOpen(false) + setSelection(i.assignedId || null) + }, [i.id, i.assignedId]) + + const suggestionCards = useMemo(() => { + const byId = new Map((i.suggestedPosts || []).map((p) => [String(p.id), p])) + return (i.suggestedIds || []).map((id, idx) => ({ + rank: idx + 1, + post: byId.get(id) || { id, unavailable: true }, + })) + }, [i.suggestedPosts, i.suggestedIds]) + + const selectedPost = useMemo(() => { + if (!selection) return null + if (manualPost && String(manualPost.id) === String(selection)) return manualPost + if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost + const hit = suggestionCards.find((c) => String(c.post.id) === String(selection)) + return hit?.post || null + }, [selection, manualPost, i.assignedPost, suggestionCards]) + + const assignMutation = useMutation({ + mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId), + onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'), + onSuccess: (_res, vars) => { + const title = selectedPost?.title || 'role' + if (vars.jobPostId) toast(`${i.name} → ${title}`, 'success') + else toast(`${i.name} unassigned`, 'success') + }, + onSettled: (_res, _err, vars) => { + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) }) + }, + }) + + const rematchMutation = useMutation({ + mutationFn: (recordId) => inboxApi.rematch(recordId), + onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'), + onSuccess: () => toast('Match re-queued', 'success'), + onSettled: (_r, _e, recordId) => { + qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) }) + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + }, + }) + + const jobChosen = Boolean(i.assignedId || selection) + const alreadyProcessed = i.processing === 'Processed' + const shortlistLocked = !alreadyProcessed && !jobChosen + const matchFailed = ['failed', 'no_text', 'dlq'].includes(i.matchStatus) + const resumeText = i.resumeText || '' + const assigned = i.assignedPost + const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending + const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending + + async function handleMove() { + if (busy || alreadyProcessed) return + if (!jobChosen) { + toast(SHORTLIST_JOB_WARNING, 'warning') + return + } + const jobId = selection || i.assignedId + if (jobId && String(jobId) !== String(i.assignedId || '')) { + try { + await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId }) + } catch { + return + } + } + onMove() + } + return (
@@ -629,67 +1020,233 @@ function ApplicationDetail({ )}
- {/* Same Subject-strip + framed-body template as /matching. */} - {!loading && ( -
-
Subject: {i.position || '(no subject)'}
- {looksLikeHtml(i.bodyHtml) ? ( - - ) : ( -
-              {i.body || 'This email has no message body.'}
-            
- )} -
- )} - - {i.hasAttachment && ( -
-
-
-
- {orDash(i.attachment)} - {i.files?.[0]?.size != null && ( - · {Math.round(i.files[0].size / 1024)} KB - )} + {assigned && ( +
+
+
+ +
+
Assigned to {assigned.title}
+
+ {[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'} +
-
-
-              {i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
-            
+
+ + +
)} +
+
+ {!loading && ( +
+
Subject: {i.position || '(no subject)'}
+ {looksLikeHtml(i.bodyHtml) ? ( + + ) : ( +
+                  {i.body || 'This email has no message body.'}
+                
+ )} +
+ )} + + {i.hasAttachment && ( +
+
+
+
+ {orDash(i.attachment)} + {i.files?.[0]?.size != null && ( + · {Math.round(i.files[0].size / 1024)} KB + )} +
+ +
+
+                  {resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
+                
+
+
+ )} +
+ +
+ {matchFailed ? ( +
+
{i.matchError || 'Matching failed for this application.'}
+ +
+ ) : ( +
+
AI verdict
+

{i.matchSummary || 'No match summary yet.'}

+ {i.matchedAt && ( +
Matched {fmtDate(i.matchedAt)}
+ )} + {i.matchReasoning && ( + + )} + {whyOpen && ( +

{i.matchReasoning}

+ )} +
+ )} + +
Suggested roles
+ {suggestionCards.length === 0 && !manualPost ? ( + +
+ + +
+
+ ) : ( + suggestionCards.map(({ rank, post }) => ( + setSelection(String(id))} + resumeText={resumeText} + /> + )) + )} + {manualPost && ( + setSelection(String(id))} + resumeText={resumeText} + /> + )} + + +
+
+
- - -
+ + {showPicker && ( + setShowPicker(false)} + onPick={(post) => { + setManualPost(post) + setSelection(String(post.id)) + }} + /> + )}
) } @@ -725,6 +1282,8 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) { /** The live tab: real fetch, real loading state, real error state. */ function EmailTab({ query, toast }) { const qc = useQueryClient() + const { can } = useAuth() + const canEdit = can('inbox.edit') const [selectedId, setSelectedId] = useState(null) const [replying, setReplying] = useState(null) const [replyBody, setReplyBody] = useState('') @@ -734,7 +1293,29 @@ function EmailTab({ query, toast }) { const selected = emails.find((e) => e.id === selectedId) const unread = emails.filter((e) => e.unread).length - const markRead = useMarkRead(toast) + const selection = useRowSelection(emails) + const setRead = useSetRead(toast) + const setReadAll = useSetReadAll(toast) + + function setReadSelected(read) { + const ids = [...selection.selectedIds] + if (!ids.length) return + setRead.mutate({ ids, read }, { + onSuccess: () => { + toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') + selection.clear() + }, + }) + } + + /** + * This list is /inbox/fetch with no params — every persisted message, no + * filter, no pagination — so the empty scope really is the whole mailbox and + * the WHERE clause matches what is on screen exactly. + */ + function setReadEverything(read) { + setReadAll.mutate({ read, filter: {} }) + } const sync = useMutation({ mutationFn: () => inboxApi.syncMailbox(), @@ -779,7 +1360,7 @@ function EmailTab({ query, toast }) { function selectEmail(e) { setSelectedId(e.id) - if (e.unread) markRead.mutate(e.id) + if (e.unread) setRead.mutate({ ids: [e.id], read: true }) } const isImported = (e) => importedIds.has(e.id) @@ -803,6 +1384,17 @@ function EmailTab({ query, toast }) {
+ {query.isSuccess && emails.length > 0 && ( + + )} {query.isPending && Fetching mailbox from the server.} {query.isError && ( @@ -818,6 +1410,11 @@ function EmailTab({ query, toast }) { className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`} onClick={() => selectEmail(e)} > + selection.toggle(e.id)} + label={`Select mail from ${e.from}`} + />
{e.from}
diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 596e300..f02e9c3 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -11,10 +11,10 @@ import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' +import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' @@ -80,14 +80,6 @@ function htmlToText(value) { return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() } -/** Requirement chip lights green when the resume text contains it (client-side). */ -function reqInResume(req, resumeText) { - if (!req || !resumeText) return false - const needle = String(req).trim().toLowerCase() - if (!needle) return false - return resumeText.toLowerCase().includes(needle) -} - function mapApplication(row) { const name = row.name || row.email || 'Unknown' const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : [] @@ -168,129 +160,6 @@ function AssignmentBadge({ item, titleById }) { return No match } -function JobCard({ post, rank, selected, onSelect, resumeText, manual }) { - const unavailable = Boolean(post?.unavailable) || !post?.title - const title = post?.title || 'Unavailable' - const meta = [ - post?.employment_type, - post?.location, - post?.experience_min != null || post?.experience_max != null - ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` - : null, - ].filter(Boolean).join(' · ') - - return ( -
!unavailable && onSelect(post.id)} - onKeyDown={(e) => { - if (unavailable) return - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onSelect(post.id) - } - }} - style={{ - cursor: unavailable ? 'not-allowed' : 'pointer', - opacity: unavailable ? 0.55 : 1, - borderColor: selected ? 'var(--primary)' : undefined, - boxShadow: selected ? 'var(--ring)' : undefined, - marginBottom: 8, - alignItems: 'flex-start', - }} - > -
-
- {manual ? 'Manual' : `AI #${rank}`} -
{title}
- {unavailable ? ( - Unavailable - ) : ( - {post.status || 'draft'} - )} - {selected && } -
- {meta &&
{meta}
} - {!unavailable && (post.requirements || []).length > 0 && ( -
- {(post.requirements || []).slice(0, 8).map((req) => { - const hit = reqInResume(req, resumeText) - return ( - - {req} - - ) - })} -
- )} -
-
- ) -} - -function PickRoleModal({ onClose, onPick }) { - const [q, setQ] = useState('') - const { data = [], isPending, isError, error } = useQuery({ - queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }), - queryFn: async () => { - const res = await jobPostsApi.list({ search: q || undefined, top: 30 }) - return Array.isArray(res?.data) ? res.data : [] - }, - }) - - return ( - Cancel} - > -
- - setQ(e.target.value)} placeholder="Search title or location…" autoFocus /> -
- {isPending && Fetching job posts.} - {isError && ( - - {friendlyAuthError(error, 'Request failed')} - - )} - {!isPending && !isError && data.length === 0 && ( - Try a different search. - )} -
- {data.map((p) => ( -
{ onPick(p); onClose() }} - > -
-
{p.title}
-
- {[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'} -
-
- {p.status} -
- ))} -
-
- ) -} - export default function Matching() { const { toast } = useToast() const { can } = useAuth() diff --git a/frontend/src/ui/SuggestedRoles.jsx b/frontend/src/ui/SuggestedRoles.jsx new file mode 100644 index 0000000..e78da6a --- /dev/null +++ b/frontend/src/ui/SuggestedRoles.jsx @@ -0,0 +1,147 @@ +/* ============================================================ + Suggested-role picker — shared by Job Matching and Recruitment Inbox. + + JobCard is the AI/manual radiogroup row. PickRoleModal is the "choose a + different role" search popup. Requirement chips light green when the + resume text contains them (client-side substring, same as Matching). + ============================================================ */ + +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' + +import Modal from './Modal' +import { Badge, EmptyState, Icon } from './primitives' +import { friendlyAuthError } from '../lib/errors' +import { qk } from '../lib/queryKeys' +import * as jobPostsApi from '../api/jobPosts' + +/** Requirement chip lights green when the resume text contains it (client-side). */ +export function reqInResume(req, resumeText) { + if (!req || !resumeText) return false + const needle = String(req).trim().toLowerCase() + if (!needle) return false + return resumeText.toLowerCase().includes(needle) +} + +export function JobCard({ post, rank, selected, onSelect, resumeText, manual }) { + const unavailable = Boolean(post?.unavailable) || !post?.title + const title = post?.title || 'Unavailable' + const meta = [ + post?.employment_type, + post?.location, + post?.experience_min != null || post?.experience_max != null + ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` + : null, + ].filter(Boolean).join(' · ') + + return ( +
!unavailable && onSelect(post.id)} + onKeyDown={(e) => { + if (unavailable) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(post.id) + } + }} + style={{ + cursor: unavailable ? 'not-allowed' : 'pointer', + opacity: unavailable ? 0.55 : 1, + borderColor: selected ? 'var(--primary)' : undefined, + boxShadow: selected ? 'var(--ring)' : undefined, + marginBottom: 8, + alignItems: 'flex-start', + }} + > +
+
+ {manual ? 'Manual' : `AI #${rank}`} +
{title}
+ {unavailable ? ( + Unavailable + ) : ( + {post.status || 'draft'} + )} + {selected && } +
+ {meta &&
{meta}
} + {!unavailable && (post.requirements || []).length > 0 && ( +
+ {(post.requirements || []).slice(0, 8).map((req) => { + const hit = reqInResume(req, resumeText) + return ( + + {req} + + ) + })} +
+ )} +
+
+ ) +} + +export function PickRoleModal({ onClose, onPick }) { + const [q, setQ] = useState('') + const { data = [], isPending, isError, error } = useQuery({ + queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }), + queryFn: async () => { + const res = await jobPostsApi.list({ search: q || undefined, top: 30 }) + return Array.isArray(res?.data) ? res.data : [] + }, + }) + + return ( + Cancel} + > +
+ + setQ(e.target.value)} placeholder="Search title or location…" autoFocus /> +
+ {isPending && Fetching job posts.} + {isError && ( + + {friendlyAuthError(error, 'Request failed')} + + )} + {!isPending && !isError && data.length === 0 && ( + Try a different search. + )} +
+ {data.map((p) => ( +
{ onPick(p); onClose() }} + > +
+
{p.title}
+
+ {[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'} +
+
+ {p.status} +
+ ))} +
+
+ ) +}