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")
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})

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 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):

View File

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

View File

@ -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 },
})
}

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() {
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