isRead working

pull/5/head
ahmed.mujtaba 2026-08-07 19:58:53 +05:00
parent 3ae716a8c4
commit 40e40a3864
6 changed files with 174 additions and 93 deletions

View File

@ -115,6 +115,7 @@ async def get_inbox_read_status(
@router.get("/inbox/all-applications") @router.get("/inbox/all-applications")
async def get_all_applications( async def get_all_applications(
record_id: str | None = Query(None), record_id: str | None = Query(None),
isread: bool = Query(default=True),
search: str | None = Query(None), search: str | None = Query(None),
top: int | None = Query(None), top: int | None = Query(None),
skip: int = Query(0, ge=0), skip: int = Query(0, ge=0),
@ -123,6 +124,10 @@ async def get_all_applications(
): ):
try: try:
service=Email(session=session) 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: if record_id:
item=await service.get_application_by_id(record_id) item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200}) return JSONResponse(content={"data":item,"total":1,"status_code":200})

11
backend/inbox/enums.py Normal file
View File

@ -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"

View File

@ -2,11 +2,11 @@ import uuid
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Optional from typing import Any, Optional
from fastapi import HTTPException from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from sqlalchemy import Column, DateTime, func, or_, update from sqlalchemy import Column, DateTime, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.ext.asyncio import AsyncSession 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 from users.models import Users
@ -50,6 +50,7 @@ class Inbox_Messages(SQLModel, table=True):
full_email_response: dict[str, Any] | None = Field( full_email_response: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSONB) default=None, sa_column=Column(JSONB)
) )
application_status: Candidate_application_Status = Field(default=Candidate_application_Status.CLOSED)
message_subject: str message_subject: str
message_body: str message_body: str
message_sent_time: str message_sent_time: str
@ -195,7 +196,7 @@ 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 cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True
): ):
statement = select(cls).order_by(cls.message_received_time.desc()) statement = select(cls).order_by(cls.message_received_time.desc())
if search: if search:
@ -204,6 +205,8 @@ class Inbox_Messages(SQLModel, table=True):
statement = statement.offset(skip) statement = statement.offset(skip)
if top is not None: if top is not None:
statement = statement.limit(top) statement = statement.limit(top)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement) result = await session.execute(statement)
return result.scalars().all() return result.scalars().all()
@ -217,33 +220,36 @@ class Inbox_Messages(SQLModel, table=True):
return result.scalars().first() return result.scalars().first()
@classmethod @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) statement = select(func.count()).select_from(cls)
if search: if search:
statement = statement.where(cls._search_filter(search)) statement = statement.where(cls._search_filter(search))
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement) result = await session.execute(statement)
return result.scalar_one() return result.scalar_one()
@classmethod @classmethod
async def apply_read_status(cls, session: AsyncSession, changes) -> int: 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: if not changes:
return 0 return 0
read_ids=[c.get("id") for c in changes if c.get("id") and c.get("isRead")] 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")] if not read_ids:
touched=0 return 0
if read_ids: result=await session.execute(
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)).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
await session.commit() await session.commit()
return touched return result.rowcount or 0
@classmethod @classmethod
async def mark_message_read(cls, session: AsyncSession, record_id): async def mark_message_read(cls, session: AsyncSession, record_id):

View File

@ -24,15 +24,15 @@ class Email:
self.token=token or EMAIL_API_TOKEN self.token=token or EMAIL_API_TOKEN
self.pending_match_ids:list[str]=[] self.pending_match_ids:list[str]=[]
async def get_all_applications(self,app_id=None): # async def get_all_applications(self,app_id=None):
try: # try:
if app_id: # if app_id:
application_lst=await Inbox_Messages.get_all_applications(self.session,message_id=app_id) # application_lst=await Inbox_Messages.get_all_applications(self.session,message_id=app_id)
else: # else:
application_lst=await Inbox_Messages.get_all_applications(self.session) # application_lst=await Inbox_Messages.get_all_applications(self.session)
return application_lst # return application_lst
except Exception as e: # except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) # raise HTTPException(status_code=500,detail=str(e))
async def service_email(self,top,skip): async def service_email(self,top,skip):
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@ -91,8 +91,11 @@ class Email:
item["files"]=files item["files"]=files
return item return item
async def get_all_applications(self,top,skip,search=None): async def get_all_applications(self,top,skip,search=None,isread:bool=True):
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search) 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] return [serialize_application(m) for m in messages]
async def get_application_by_id(self,record_id): async def get_application_by_id(self,record_id):
@ -122,8 +125,11 @@ class Email:
task_ids.append(task.task_id) task_ids.append(task.task_id)
return task_ids return task_ids
async def count_inbox_messages(self,search=None): async def count_inbox_messages(self,search=None,isread:bool=True):
return await Inbox_Messages.count_inbox_messages(self.session,search) 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): async def mark_read(self,record_id):
message=await Inbox_Messages.mark_message_read(self.session,record_id) message=await Inbox_Messages.mark_message_read(self.session,record_id)

View File

@ -17,9 +17,12 @@ export function listMessages() {
* Unlike /inbox/fetch this one IS permissioned server-side * Unlike /inbox/fetch this one IS permissioned server-side
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403. * (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', { 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 },
}) })
} }

View File

@ -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() { export default function Inbox() {
const { toast } = useToast() const { toast } = useToast()
const navigate = useNavigate() const navigate = useNavigate()
@ -95,47 +169,30 @@ export default function Inbox() {
const [assigning, setAssigning] = useState(null) const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null) const [noting, setNoting] = useState(null)
/** // Only the Unread tab filters server-side; every other tab omits the param and
* GET /inbox/all-applications. READ-ONLY: inbox_messages has no columns for // the backend's default (true) means "no filter".
* processing state, duplicates, recruiter, phone, experience or an ATS score, const isread = tab === 'Unread' ? false : undefined
* 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.
*/
const applicationsQuery = useQuery({ const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(), queryKey: qk.mailbox.applications({ isread }),
queryFn: async () => { queryFn: () => fetchApplications({ isread }),
const res = await inboxApi.listApplications() enabled: tab !== 'Email',
const rows = Array.isArray(res?.data) ? res.data : [] })
return rows.map((row) => {
const name = row.name || row.email || 'Unknown' /**
return { * The tab badges need whole-table counts, which a server-filtered response
id: String(row.id), * cannot give and there is no counts endpoint. So the unfiltered set stays
name, * loaded for them. On every tab except Unread this resolves to the SAME query
initials: initialsOf(name), * key as the list above, so React Query serves both from one request.
color: avatarColor(name), */
email: row.email || '', const countsQuery = useQuery({
position: row.position || '(no subject)', queryKey: qk.mailbox.applications({ isread: undefined }),
...sourceFrom(row.source), queryFn: () => fetchApplications({}),
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),
}
})
},
enabled: tab !== 'Email', enabled: tab !== 'Email',
}) })
const inbox = applicationsQuery.data ?? [] const inbox = applicationsQuery.data ?? []
const allApplications = countsQuery.data ?? []
const emailsQuery = useQuery({ const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(), queryKey: qk.mailbox.messages(),
@ -167,20 +224,25 @@ export default function Inbox() {
}) })
const counts = useMemo( 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, 'All Applications': allApplications.length,
Unread: inbox.filter((i) => i.processing === 'Unread').length, Unread: allApplications.filter((i) => i.processing === 'Unread').length,
Imported: inbox.filter((i) => i.processing === 'Imported').length, Imported: allApplications.filter((i) => i.processing === 'Imported').length,
Processed: inbox.filter((i) => i.processing === 'Processed').length, Processed: allApplications.filter((i) => i.processing === 'Processed').length,
Rejected: inbox.filter((i) => i.processing === 'Rejected').length, Rejected: allApplications.filter((i) => i.processing === 'Rejected').length,
Duplicates: inbox.filter((i) => i.duplicate).length, Duplicates: allApplications.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length, Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}), }),
[inbox, emailsQuery.data], [allApplications, emailsQuery.data],
) )
const list = useMemo(() => { const list = useMemo(() => {
let l = inbox 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') if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported') else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed') 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) const selected = inbox.find((i) => i.id === selectedId)
// The one mutation this tab CAN persist. Note it needs INBOX_EDIT while the // The one mutation these tabs CAN persist everything else on them is
// list only needs INBOX_VIEW, so a view-only user gets a 403 here. // disabled until the endpoints exist.
const markApplicationRead = useMutation({ const markRead = useMarkRead(toast)
mutationFn: (recordId) => inboxApi.markRead(recordId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
},
onError: (err) => toast(friendlyAuthError(err, 'Could not mark application read.'), 'error'),
})
function select(id) { function select(id) {
setSelectedId(id) setSelectedId(id)
const item = inbox.find((i) => i.id === 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) { function makeCandidate(item, job, cs) {
@ -571,13 +627,7 @@ function EmailTab({ query, jobs, updateCandidates, toast }) {
const selected = emails.find((e) => e.id === selectedId) const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length const unread = emails.filter((e) => e.unread).length
const markRead = useMutation({ const markRead = useMarkRead(toast)
mutationFn: (recordId) => inboxApi.markRead(recordId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
},
onError: (err) => toast(friendlyAuthError(err, 'Could not mark message read.'), 'error'),
})
// Refetching the list alone only re-reads rows already in our DB. GET // 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 // /email/fetch is the Graph proxy pull that inserts new mail and enqueues the