diff --git a/backend/inbox/app.py b/backend/inbox/app.py index 4162bcf..cdd4ed8 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -115,6 +115,7 @@ async def get_inbox_read_status( @router.get("/inbox/all-applications") async def get_all_applications( record_id: str | None = Query(None), + isread: bool = Query(default=True), search: str | None = Query(None), top: int | None = Query(None), skip: int = Query(0, ge=0), @@ -123,6 +124,10 @@ async def get_all_applications( ): try: service=Email(session=session) + if isread==False: + items=await service.get_all_applications(top, skip, search, isread=False) + total=await service.count_inbox_messages(search, isread=False) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) if record_id: item=await service.get_application_by_id(record_id) return JSONResponse(content={"data":item,"total":1,"status_code":200}) diff --git a/backend/inbox/enums.py b/backend/inbox/enums.py new file mode 100644 index 0000000..992c23b --- /dev/null +++ b/backend/inbox/enums.py @@ -0,0 +1,11 @@ +from enum import Enum + +# (str, Enum), like EnumRoles and PermissionTag: a bare Enum member is not JSON +# serializable, so JSONResponse raises the moment a serializer emits this field. +class Candidate_application_Status(str, Enum): + PROCESS="PROCESS" + PENDING="PENDING" + APPROVED="APPROVED" + REJECTED="REJECTED" + ONHOLD="ONHOLD" + CLOSED="CLOSED" \ No newline at end of file diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 820e077..039a3ba 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -2,11 +2,11 @@ import uuid from datetime import datetime, timezone from typing import Any, Optional from fastapi import HTTPException - +from inbox.enums import Candidate_application_Status from sqlalchemy import Column, DateTime, func, or_, update from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import Field, Relationship, SQLModel, select +from sqlmodel import Field, Relationship, SQLModel, select, true from users.models import Users @@ -50,6 +50,7 @@ class Inbox_Messages(SQLModel, table=True): full_email_response: dict[str, Any] | None = Field( default=None, sa_column=Column(JSONB) ) + application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED) message_subject: str message_body: str message_sent_time: str @@ -195,7 +196,7 @@ class Inbox_Messages(SQLModel, table=True): @classmethod async def get_inbox_messages( - cls, session: AsyncSession, top: int | None, skip: int, search: str | None + cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True ): statement = select(cls).order_by(cls.message_received_time.desc()) if search: @@ -204,6 +205,8 @@ class Inbox_Messages(SQLModel, table=True): 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() @@ -217,33 +220,36 @@ class Inbox_Messages(SQLModel, table=True): return result.scalars().first() @classmethod - async def count_inbox_messages(cls, session: AsyncSession, search: str | None): + async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True): statement = select(func.count()).select_from(cls) if search: statement = statement.where(cls._search_filter(search)) + if isread==False: + statement = statement.where(cls.message_read==False) result = await session.execute(statement) return result.scalar_one() @classmethod async def apply_read_status(cls, session: AsyncSession, changes) -> int: - """[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched.""" + """[{id, isRead, ...}] -> bulk UPDATE message_read. Returns rows touched. + + read is a ONE-WAY LATCH: only false -> true is applied, never the reverse. + mark_message_read writes the local column only — nothing pushes the state + back to Outlook — so upstream keeps reporting isRead=false and the + 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. + """ if not changes: return 0 read_ids=[c.get("id") for c in changes if c.get("id") and c.get("isRead")] - unread_ids=[c.get("id") for c in changes if c.get("id") and not c.get("isRead")] - touched=0 - if read_ids: - result=await session.execute( - update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True) - ) - touched+=result.rowcount or 0 - if unread_ids: - result=await session.execute( - update(cls).where(cls.message_id.in_(unread_ids)).values(message_read=False) - ) - touched+=result.rowcount or 0 + if not read_ids: + return 0 + result=await session.execute( + update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True) + ) await session.commit() - return touched + return result.rowcount or 0 @classmethod async def mark_message_read(cls, session: AsyncSession, record_id): diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 889f470..62db458 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -24,15 +24,15 @@ class Email: self.token=token or EMAIL_API_TOKEN self.pending_match_ids:list[str]=[] - async def get_all_applications(self,app_id=None): - try: - if app_id: - application_lst=await Inbox_Messages.get_all_applications(self.session,message_id=app_id) - else: - application_lst=await Inbox_Messages.get_all_applications(self.session) - return application_lst - except Exception as e: - raise HTTPException(status_code=500,detail=str(e)) + # async def get_all_applications(self,app_id=None): + # try: + # if app_id: + # application_lst=await Inbox_Messages.get_all_applications(self.session,message_id=app_id) + # else: + # application_lst=await Inbox_Messages.get_all_applications(self.session) + # return application_lst + # except Exception as e: + # raise HTTPException(status_code=500,detail=str(e)) async def service_email(self,top,skip): async with httpx.AsyncClient() as client: @@ -91,8 +91,11 @@ class Email: item["files"]=files return item - async def get_all_applications(self,top,skip,search=None): - messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search) + async def get_all_applications(self,top,skip,search=None,isread:bool=True): + if isread==False: + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread) + else: + messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search) return [serialize_application(m) for m in messages] async def get_application_by_id(self,record_id): @@ -122,8 +125,11 @@ class Email: task_ids.append(task.task_id) return task_ids - async def count_inbox_messages(self,search=None): - return await Inbox_Messages.count_inbox_messages(self.session,search) + async def count_inbox_messages(self,search=None,isread:bool=True): + if isread==False: + return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False) + else: + return await Inbox_Messages.count_inbox_messages(self.session,search) async def mark_read(self,record_id): message=await Inbox_Messages.mark_message_read(self.session,record_id) diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index d1c969a..4f3acd9 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -17,9 +17,12 @@ export function listMessages() { * Unlike /inbox/fetch this one IS permissioned server-side * (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403. */ -export function listApplications({ search, top, skip, recordId } = {}) { +export function listApplications({ search, top, skip, recordId, isread } = {}) { return request('/inbox/all-applications', { - params: { search, top, skip, record_id: recordId }, + // `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 + // undefined but keeps false, so `isread: undefined` sends no param at all. + params: { search, top, skip, record_id: recordId, isread }, }) } diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index bb697db..de76a1c 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -79,6 +79,80 @@ function SourceChip({ item }) { ) } +/** + * GET /inbox/all-applications -> the shape the application tabs render. + * + * READ-ONLY: inbox_messages has no columns for processing state, duplicates, + * recruiter, phone, experience or an ATS score, so those arrive null and every + * mutating action on these tabs is disabled until the endpoints exist. + * `processing` is derived from message_read alone, which is why the Imported / + * Processed / Rejected / Duplicates tabs read empty. + */ +async function fetchApplications(params) { + const res = await inboxApi.listApplications(params) + const rows = Array.isArray(res?.data) ? res.data : [] + return rows.map((row) => { + const name = row.name || row.email || 'Unknown' + return { + id: String(row.id), + name, + initials: initialsOf(name), + color: avatarColor(name), + email: row.email || '', + position: row.position || '(no subject)', + ...sourceFrom(row.source), + received: parseDate(row.received), + unread: Boolean(row.unread), + processing: row.processing || 'Unread', + resumeStatus: row.resume_status || 'Pending', + attachment: row.attachment, + hasAttachment: Boolean(row.has_attachment), + resumeText: row.resume_text || '', + atsScore: row.ats_score, + phone: row.phone, + experience: row.experience, + recruiter: row.recruiter, + duplicate: Boolean(row.duplicate), + } + }) +} + +/** + * 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. + */ +function useMarkRead(toast) { + const qc = useQueryClient() + return useMutation({ + mutationFn: (recordId) => inboxApi.markRead(recordId), + onMutate: async (recordId) => { + 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 + )) + return { previous } + }, + onError: (err, _recordId, ctx) => { + for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) + toast(friendlyAuthError(err, 'Could not mark as read.'), 'error') + }, + onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), + }) +} + export default function Inbox() { const { toast } = useToast() const navigate = useNavigate() @@ -95,47 +169,30 @@ export default function Inbox() { const [assigning, setAssigning] = useState(null) const [noting, setNoting] = useState(null) - /** - * GET /inbox/all-applications. READ-ONLY: inbox_messages has no columns for - * processing state, duplicates, recruiter, phone, experience or an ATS score, - * so those arrive null and every mutating action on this tab is disabled until - * the endpoints exist. `processing` is derived from message_read alone, which - * is why the Imported / Processed / Rejected / Duplicates tabs read empty. - */ + // Only the Unread tab filters server-side; every other tab omits the param and + // the backend's default (true) means "no filter". + const isread = tab === 'Unread' ? false : undefined + const applicationsQuery = useQuery({ - queryKey: qk.mailbox.applications(), - queryFn: async () => { - const res = await inboxApi.listApplications() - const rows = Array.isArray(res?.data) ? res.data : [] - return rows.map((row) => { - const name = row.name || row.email || 'Unknown' - return { - id: String(row.id), - name, - initials: initialsOf(name), - color: avatarColor(name), - email: row.email || '', - position: row.position || '(no subject)', - ...sourceFrom(row.source), - received: parseDate(row.received), - unread: Boolean(row.unread), - processing: row.processing || 'Unread', - resumeStatus: row.resume_status || 'Pending', - attachment: row.attachment, - hasAttachment: Boolean(row.has_attachment), - resumeText: row.resume_text || '', - atsScore: row.ats_score, - phone: row.phone, - experience: row.experience, - recruiter: row.recruiter, - duplicate: Boolean(row.duplicate), - } - }) - }, + queryKey: qk.mailbox.applications({ isread }), + queryFn: () => fetchApplications({ isread }), + enabled: tab !== 'Email', + }) + + /** + * The tab badges need whole-table counts, which a server-filtered response + * cannot give — and there is no counts endpoint. So the unfiltered set stays + * loaded for them. On every tab except Unread this resolves to the SAME query + * key as the list above, so React Query serves both from one request. + */ + const countsQuery = useQuery({ + queryKey: qk.mailbox.applications({ isread: undefined }), + queryFn: () => fetchApplications({}), enabled: tab !== 'Email', }) const inbox = applicationsQuery.data ?? [] + const allApplications = countsQuery.data ?? [] const emailsQuery = useQuery({ queryKey: qk.mailbox.messages(), @@ -167,20 +224,25 @@ export default function Inbox() { }) const counts = useMemo( + // Counted off the UNFILTERED set — `inbox` is server-filtered on the Unread + // tab, so counting it there would report the unread total for every badge. () => ({ - 'All Applications': inbox.length, - Unread: inbox.filter((i) => i.processing === 'Unread').length, - Imported: inbox.filter((i) => i.processing === 'Imported').length, - Processed: inbox.filter((i) => i.processing === 'Processed').length, - Rejected: inbox.filter((i) => i.processing === 'Rejected').length, - Duplicates: inbox.filter((i) => i.duplicate).length, + 'All Applications': allApplications.length, + Unread: allApplications.filter((i) => i.processing === 'Unread').length, + Imported: allApplications.filter((i) => i.processing === 'Imported').length, + Processed: allApplications.filter((i) => i.processing === 'Processed').length, + Rejected: allApplications.filter((i) => i.processing === 'Rejected').length, + Duplicates: allApplications.filter((i) => i.duplicate).length, Email: (emailsQuery.data ?? []).filter((e) => e.unread).length, }), - [inbox, emailsQuery.data], + [allApplications, emailsQuery.data], ) const list = useMemo(() => { let l = inbox + // Unread is already filtered server-side; re-applying it client-side is what + // makes the optimistic mark-read drop the row from the list immediately + // instead of leaving it until the refetch lands. if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread') else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported') else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed') @@ -192,20 +254,14 @@ export default function Inbox() { const selected = inbox.find((i) => i.id === selectedId) - // The one mutation this tab CAN persist. Note it needs INBOX_EDIT while the - // list only needs INBOX_VIEW, so a view-only user gets a 403 here. - const markApplicationRead = useMutation({ - mutationFn: (recordId) => inboxApi.markRead(recordId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.mailbox.all() }) - }, - onError: (err) => toast(friendlyAuthError(err, 'Could not mark application read.'), 'error'), - }) + // The one mutation these tabs CAN persist — everything else on them is + // disabled until the endpoints exist. + const markRead = useMarkRead(toast) function select(id) { setSelectedId(id) const item = inbox.find((i) => i.id === id) - if (item?.unread) markApplicationRead.mutate(id) + if (item?.unread) markRead.mutate(id) } function makeCandidate(item, job, cs) { @@ -571,13 +627,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) { const selected = emails.find((e) => e.id === selectedId) const unread = emails.filter((e) => e.unread).length - const markRead = useMutation({ - mutationFn: (recordId) => inboxApi.markRead(recordId), - onSuccess: () => { - qc.invalidateQueries({ queryKey: qk.mailbox.all() }) - }, - onError: (err) => toast(friendlyAuthError(err, 'Could not mark message read.'), 'error'), - }) + const markRead = useMarkRead(toast) // Refetching the list alone only re-reads rows already in our DB. GET // /email/fetch is the Graph proxy pull that inserts new mail and enqueues the