From 3ae716a8c4ebd1f799b945de64e35caf00b6bb36 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Fri, 7 Aug 2026 19:17:16 +0500 Subject: [PATCH] frontend all aplications --- backend/inbox/app.py | 20 ++-- backend/inbox/serializers.py | 64 ++++++++++- backend/inbox/views.py | 12 +- frontend/src/api/inbox.js | 12 ++ frontend/src/lib/queryKeys.js | 1 + frontend/src/screens/Inbox.jsx | 204 +++++++++++++++++++++++++-------- 6 files changed, 251 insertions(+), 62 deletions(-) diff --git a/backend/inbox/app.py b/backend/inbox/app.py index db68767..4162bcf 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -1,11 +1,9 @@ -from typing import Any from fastapi import APIRouter,Depends, Query from fastapi.responses import JSONResponse from fastapi import HTTPException from db_setup import get_session from sqlalchemy.ext.asyncio import AsyncSession from inbox.views import Email -import uuid from users.permissions import PermissionTag, require_permission from dotenv import load_dotenv load_dotenv() @@ -116,14 +114,22 @@ async def get_inbox_read_status( @router.get("/inbox/all-applications") async def get_all_applications( - app_id:uuid.UUID|int=Query(None), - current_user:dict=Depends(require_permission(PermissionTag.INBOX_VIEW)), - session:AsyncSession=Depends(get_session), + record_id: str | None = Query(None), + search: str | None = Query(None), + top: int | None = Query(None), + skip: int = Query(0, ge=0), + current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)), + session: AsyncSession = Depends(get_session), ): try: service=Email(session=session) - data=await service.get_all_applications(app_id) - return JSONResponse(content={"data":data,"total":1,"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}) + + items=await service.get_all_applications(top,skip,search) + total=await service.count_inbox_messages(search) + return JSONResponse(content={"data":items,"total":total,"status_code":200}) except HTTPException: raise except Exception as e: diff --git a/backend/inbox/serializers.py b/backend/inbox/serializers.py index 1a1ec02..6893e10 100644 --- a/backend/inbox/serializers.py +++ b/backend/inbox/serializers.py @@ -2,9 +2,19 @@ from pathlib import Path from inbox.models import Inbox_Messages +# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render. +_RESUME_STATUS = { + "processing": "Parsing", + "matched": "Parsed", + "no_text": "Failed", + "failed": "Failed", + "dlq": "Failed", + "skipped": "Pending", +} -def serialize_message(message: Inbox_Messages) -> dict: - """inbox_messages row -> the shape the #inbox Email tab renders.""" + +def _sender_name(message: Inbox_Messages) -> str: + """Graph's display name when the payload carries one, else the raw address.""" sender_name = message.message_from full = message.full_email_response if isinstance(full, dict): @@ -15,12 +25,21 @@ def serialize_message(message: Inbox_Messages) -> dict: name = email_address.get("name") if name: sender_name = name + return sender_name - attachment_name = None + +def _attachment_name(message: Inbox_Messages) -> str | None: if message.file_name: - attachment_name = message.file_name.split(",")[0].strip() or None - elif message.file_path: - attachment_name = Path(message.file_path.split(",")[0].strip()).name or None + return message.file_name.split(",")[0].strip() or None + if message.file_path: + return Path(message.file_path.split(",")[0].strip()).name or None + return None + + +def serialize_message(message: Inbox_Messages) -> dict: + """inbox_messages row -> the shape the #inbox Email tab renders.""" + sender_name = _sender_name(message) + attachment_name = _attachment_name(message) return { "id": str(message.id), @@ -47,3 +66,36 @@ def serialize_message(message: Inbox_Messages) -> dict: "match_error": message.match_error, "matched_at": message.matched_at.isoformat() if message.matched_at else None, } + + +def serialize_application(message: Inbox_Messages) -> dict: + """inbox_messages row -> the shape the #inbox All Applications tab renders. + + `position` is the mail subject and `source` is the To address, which is where + the board tag (Rozee, Mustakbil, Employee Referral, ...) lands. + + The tab also wants ats_score, phone, experience, recruiter, duplicate and a + processing state beyond read/unread. inbox_messages has no columns for any of + those, so they come back null instead of invented — see the note in + inbox/file_decoder.py. `processing` is derived from message_read alone, so it + is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column. + """ + return { + "id": str(message.id), + "name": _sender_name(message), + "email": message.message_from, + "position": message.message_subject, + "source": message.message_to, + "received": message.message_received_time, + "unread": not message.message_read, + "processing": "Read" if message.message_read else "Unread", + "resume_status": _RESUME_STATUS.get(message.match_status, "Pending"), + "attachment": _attachment_name(message), + "has_attachment": message.attachment, + "resume_text": message.resume_text, + "ats_score": None, + "phone": None, + "experience": None, + "recruiter": None, + "duplicate": None, + } diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 1fba17d..889f470 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -3,7 +3,7 @@ import httpx,os from fastapi import HTTPException from inbox.models import Inbox_Messages from inbox.file_decoder import decode_attachment -from inbox.serializers import serialize_message +from inbox.serializers import serialize_application, serialize_message from inbox.plugins import ( EMAIL_API_TOKEN, fetch_message_read_status, @@ -91,6 +91,16 @@ 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) + return [serialize_application(m) for m in messages] + + async def get_application_by_id(self,record_id): + message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) + if not message: + raise HTTPException(status_code=404,detail="Application not found") + return serialize_application(message) + async def queue_rematch(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 df813d3..d1c969a 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -11,6 +11,18 @@ export function listMessages() { return request('/inbox/fetch') } +/** + * Persisted applications — the shape the All Applications tab renders. + * + * 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 } = {}) { + return request('/inbox/all-applications', { + params: { search, top, skip, record_id: recordId }, + }) +} + /** Triggers the Graph proxy to pull new mail and persist it. */ export function syncMailbox({ token, top, skip } = {}) { return request('/email/fetch', { params: { token, top, skip } }) diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index eb9464b..1830e71 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -19,6 +19,7 @@ export const qk = { mailbox: { all: () => ['mailbox'], messages: () => ['mailbox', 'messages'], + applications: (p = {}) => ['mailbox', 'applications', p], }, // --- seed-backed buckets --- diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 308d481..bb697db 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -23,7 +23,8 @@ import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' import { atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob, - initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY, + initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta, + TODAY, } from '../data/seed' const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email'] @@ -51,22 +52,20 @@ function parseDate(value) { return Number.isNaN(d.getTime()) ? null : d } -function resumeText(i) { - return `${i.name.toUpperCase()} -${i.email} · ${i.phone} -${'—'.repeat(30)} -PROFESSIONAL SUMMARY -${i.experience} years of experience. Applied for ${i.position} via ${i.source}. - -EXPERIENCE -• ${pick(companies)} — Senior role (2021–Present) -• ${pick(companies)} — Associate (2018–2021) - -EDUCATION -• Bachelor's Degree, Computer Science - -SKILLS -• ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}` +/** + * `source` arrives as the raw To address, because that is where the board tag + * lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip + * everything but letters from both sides so "Employee Referral" still matches + * "employee-referral@", and keep the brand colour SourceChip paints from. + * Nothing matches -> show the first recipient verbatim rather than guess. + */ +function sourceFrom(messageTo) { + const raw = (messageTo || '').trim() + if (!raw) return { source: 'Unknown', sourceMeta: null } + const flat = raw.toLowerCase().replace(/[^a-z]/g, '') + const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, ''))) + if (hit) return { source: hit, sourceMeta: sourceMeta[hit] } + return { source: raw.split(',')[0].trim(), sourceMeta: null } } function SourceChip({ item }) { @@ -83,7 +82,7 @@ function SourceChip({ item }) { export default function Inbox() { const { toast } = useToast() const navigate = useNavigate() - const { data: inbox = [] } = useQuery(seedQuery('inbox')) + const qc = useQueryClient() const { data: jobs = [] } = useQuery(seedQuery('jobs')) const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('inbox') @@ -96,6 +95,48 @@ 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. + */ + 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), + } + }) + }, + enabled: tab !== 'Email', + }) + + const inbox = applicationsQuery.data ?? [] + const emailsQuery = useQuery({ queryKey: qk.mailbox.messages(), queryFn: async () => { @@ -151,9 +192,20 @@ 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'), + }) + function select(id) { setSelectedId(id) - updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i))) + const item = inbox.find((i) => i.id === id) + if (item?.unread) markApplicationRead.mutate(id) } function makeCandidate(item, job, cs) { @@ -250,7 +302,15 @@ export default function Inbox() {
- {list.length === 0 ? ( + {applicationsQuery.isPending && ( + Fetching applications from the server. + )} + {applicationsQuery.isError && ( + + {friendlyAuthError(applicationsQuery.error, 'Request failed')} + + )} + {applicationsQuery.isSuccess && list.length === 0 ? ( No applications in this view. ) : ( list.map((i) => ( @@ -271,8 +331,15 @@ export default function Inbox() {
{i.processing}
-
{relTime(Math.round((NOW - i.received) / 60000))}
-
+
+ {i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'} +
+ {/* No ATS score exists server-side — the agent returns a + verdict, not a number. The chip stays off rather than + rendering a placeholder that reads as a real score. */} + {i.atsScore != null && ( +
+ )}
)) @@ -316,13 +383,17 @@ export default function Inbox() { } > -
{resumeText(previewing)}
+
+            {previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
+          
)} @@ -363,9 +434,17 @@ export default function Inbox() { ) } +/** Fields inbox_messages has no column for come back null; show a dash, not "null". */ +function orDash(value, suffix = '') { + return value == null || value === '' ? '—' : `${value}${suffix}` +} + function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) { 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)' + // Every action below writes to a table column or an endpoint that does not + // exist yet, so they are disabled rather than silently dropping the click. + const noBackend = 'Needs a backend endpoint — not implemented yet' return (
@@ -381,43 +460,72 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
-
-
-
{i.atsScore}
+ {i.atsScore != null && ( +
+
+
{i.atsScore}
+
+
ATS Score
-
ATS Score
-
+ )}
-
Email
{i.email}
-
Phone
{i.phone}
-
Experience
{i.experience} years
-
Assigned Recruiter
{i.recruiter}
-
Received
{fmtDate(i.received)}
+
Email
{orDash(i.email)}
+
Phone
{orDash(i.phone)}
+
Experience
{orDash(i.experience, ' years')}
+
Assigned Recruiter
{orDash(i.recruiter)}
-
Match
-
{recLabel}
+
Received
+
{i.received ? fmtDate(i.received) : '—'}
+ {i.atsScore != null && ( +
+
Match
+
{recLabel}
+
+ )}
-
-
-
-
{i.attachment}
- + {i.hasAttachment && ( +
+
+
+
{orDash(i.attachment)}
+ +
+ {/* The real extracted PDF text (inbox_messages.resume_text), written + by the matching task. Empty until that task has run. */} +
+              {i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
+            
-
{resumeText(i)}
-
+ )}
- - - - - - + + + + +