Merge branch 'main' of https://git.utopiadeals.com/utopia-ai/HR-ATS-Portal into Talha
Deploy to S3 / deploy (push) Successful in 35s
Details
Deploy to S3 / deploy (push) Successful in 35s
Details
commit
5a28b35dd9
|
|
@ -69,9 +69,26 @@ async def sync_mailbox(run_id:str) -> dict:
|
||||||
service=Email(session=session)
|
service=Email(session=session)
|
||||||
if not service.token:
|
if not service.token:
|
||||||
return await _fail(run_id,"EMAIL_API_TOKEN is not configured")
|
return await _fail(run_id,"EMAIL_API_TOKEN is not configured")
|
||||||
|
|
||||||
|
async def on_progress(processed,expected,entries):
|
||||||
|
ingested=sum(1 for e in entries if e.get("status") in ("ingested","known"))
|
||||||
|
skipped=sum(1 for e in entries if e.get("status")=="skipped")
|
||||||
|
errors=sum(1 for e in entries if e.get("status")=="error")
|
||||||
|
await MailboxSyncRun.update_run(session,run_id,{
|
||||||
|
"entries":list(entries),
|
||||||
|
"triage":{
|
||||||
|
"expected":expected,
|
||||||
|
"processed":processed,
|
||||||
|
"ingested":ingested,
|
||||||
|
"skipped":skipped,
|
||||||
|
"errors":errors,
|
||||||
|
"total":expected,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
summary=await service.run_mailbox_sync_page(
|
summary=await service.run_mailbox_sync_page(
|
||||||
top=top,skip=skip,test_on=test_on,
|
top=top,skip=skip,test_on=test_on,on_progress=on_progress,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("mailbox sync failed for run %s",run_id)
|
logger.exception("mailbox sync failed for run %s",run_id)
|
||||||
|
|
|
||||||
|
|
@ -356,13 +356,18 @@ class Email:
|
||||||
raise HTTPException(status_code=404,detail="No sync runs yet")
|
raise HTTPException(status_code=404,detail="No sync runs yet")
|
||||||
return serialize_mailbox_sync_run(row)
|
return serialize_mailbox_sync_run(row)
|
||||||
|
|
||||||
async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True):
|
async def run_mailbox_sync_page(self,top=100,skip=0,test_on=True,on_progress=None):
|
||||||
"""Pull one Outlook page, triage, ingest, enqueue matching. Returns summary.
|
"""Pull one Outlook page, triage, ingest, enqueue matching. Returns summary.
|
||||||
|
|
||||||
Shared by the legacy synchronous /email/fetch and the background Taskiq worker.
|
Shared by the legacy synchronous /email/fetch and the background Taskiq worker.
|
||||||
|
`on_progress(processed, expected, entries)` is optional; the mailbox_sync
|
||||||
|
worker uses it so the Sync button can poll a live percentage.
|
||||||
"""
|
"""
|
||||||
data=await self.service_email(top,skip)
|
data=await self.service_email(top,skip)
|
||||||
value=data.get("value") or []
|
value=data.get("value") or []
|
||||||
|
expected=len(value)
|
||||||
|
if on_progress:
|
||||||
|
await on_progress(0,expected,[])
|
||||||
decisions=await self.triage_round([item.get("id") for item in value])
|
decisions=await self.triage_round([item.get("id") for item in value])
|
||||||
entries=[]
|
entries=[]
|
||||||
for item in value:
|
for item in value:
|
||||||
|
|
@ -398,6 +403,8 @@ class Email:
|
||||||
"triage_status":"error",
|
"triage_status":"error",
|
||||||
})
|
})
|
||||||
self.triage_errors.append(str(message_id))
|
self.triage_errors.append(str(message_id))
|
||||||
|
if on_progress:
|
||||||
|
await on_progress(len(entries),expected,entries)
|
||||||
|
|
||||||
if self.pending_match_ids:
|
if self.pending_match_ids:
|
||||||
await self.enqueue_matching(list(self.pending_match_ids),force=False)
|
await self.enqueue_matching(list(self.pending_match_ids),force=False)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import Modal from '../ui/Modal'
|
||||||
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
|
||||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
|
import SyncButton from '../ui/SyncButton'
|
||||||
import { Tabs } from '../ui/Tabs'
|
import { Tabs } from '../ui/Tabs'
|
||||||
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
|
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
|
||||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
import { Avatar, Badge, EmptyState, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||||
|
|
@ -38,6 +39,25 @@ const FORM_TABS = ['All Applications', 'Processed', 'Rejected', 'Duplicates']
|
||||||
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
|
/** Inbox GET `top` / sheet GET `limit` both cap at 500. */
|
||||||
const PAGE_SIZE_MAX = 500
|
const PAGE_SIZE_MAX = 500
|
||||||
|
|
||||||
|
const SYNC_RUN_KEY = 'mailbox_sync_run_id'
|
||||||
|
|
||||||
|
function readStoredSyncRunId() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(SYNC_RUN_KEY) || null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeSyncRunId(id) {
|
||||||
|
try {
|
||||||
|
if (id) localStorage.setItem(SYNC_RUN_KEY, id)
|
||||||
|
else localStorage.removeItem(SYNC_RUN_KEY)
|
||||||
|
} catch {
|
||||||
|
/* private mode / quota — polling still works in-session */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
|
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
|
||||||
const CHANNELS = [
|
const CHANNELS = [
|
||||||
{ key: 'email', label: 'Email', icon: 'mail' },
|
{ key: 'email', label: 'Email', icon: 'mail' },
|
||||||
|
|
@ -959,6 +979,63 @@ export default function Inbox() {
|
||||||
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
|
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId())
|
||||||
|
const syncToastShown = useRef(null)
|
||||||
|
|
||||||
|
const sync = useMutation({
|
||||||
|
mutationFn: () => inboxApi.startMailboxSync(),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
const id = res?.data?.id
|
||||||
|
if (id) {
|
||||||
|
storeSyncRunId(id)
|
||||||
|
setSyncRunId(id)
|
||||||
|
}
|
||||||
|
toast('Mailbox sync started — safe to leave this page', 'info')
|
||||||
|
},
|
||||||
|
onError: (err) => toast(friendlyAuthError(err, 'Sync failed'), 'error'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const syncRun = useQuery({
|
||||||
|
queryKey: qk.mailbox.sync(syncRunId),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await inboxApi.getMailboxSync(syncRunId)
|
||||||
|
return res?.data ?? null
|
||||||
|
},
|
||||||
|
enabled: Boolean(syncRunId),
|
||||||
|
refetchInterval: (q) => {
|
||||||
|
const status = q.state.data?.status
|
||||||
|
return status === 'queued' || status === 'running' ? 800 : false
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const run = syncRun.data
|
||||||
|
if (!run?.id) return undefined
|
||||||
|
if (run.status === 'completed' && syncToastShown.current !== run.id) {
|
||||||
|
syncToastShown.current = run.id
|
||||||
|
const t = run.triage
|
||||||
|
toast(
|
||||||
|
t
|
||||||
|
? `Mailbox synced — ${t.ingested ?? 0} imported, ${t.skipped ?? 0} filtered out`
|
||||||
|
: 'Mailbox synced',
|
||||||
|
'success',
|
||||||
|
)
|
||||||
|
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
storeSyncRunId(null)
|
||||||
|
setSyncRunId(null)
|
||||||
|
}, 1800)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
if (run.status === 'failed' && syncToastShown.current !== run.id) {
|
||||||
|
syncToastShown.current = run.id
|
||||||
|
toast(run.error || 'Sync failed', 'error')
|
||||||
|
storeSyncRunId(null)
|
||||||
|
setSyncRunId(null)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}, [qc, syncRun.data, toast])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|
@ -989,6 +1066,14 @@ export default function Inbox() {
|
||||||
{isForms && (
|
{isForms && (
|
||||||
<span className="integration-status"><span className="pulse" />Google Sheets · Form data</span>
|
<span className="integration-status"><span className="pulse" />Google Sheets · Form data</span>
|
||||||
)}
|
)}
|
||||||
|
{!isForms && (
|
||||||
|
<SyncButton
|
||||||
|
run={syncRun.data}
|
||||||
|
pending={sync.isPending}
|
||||||
|
disabled={!canEdit}
|
||||||
|
onClick={() => sync.mutate()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||||
<Icon name="upload" /> Upload CVs
|
<Icon name="upload" /> Upload CVs
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1109,6 +1109,31 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.integration-status .pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: currentColor; animation: pulse 1.8s infinite; }
|
.integration-status .pulse::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: currentColor; animation: pulse 1.8s infinite; }
|
||||||
@keyframes pulse { 0% { transform: scale(1); opacity: .7; } 100% { transform: scale(3); opacity: 0; } }
|
@keyframes pulse { 0% { transform: scale(1); opacity: .7; } 100% { transform: scale(3); opacity: 0; } }
|
||||||
|
|
||||||
|
/* Mailbox Sync — same metrics as .btn so it lines up with Upload CVs.
|
||||||
|
No fill animation; percentage is text from the worker. */
|
||||||
|
.sync-btn {
|
||||||
|
min-width: 7.5rem;
|
||||||
|
}
|
||||||
|
.sync-btn-spinner {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid color-mix(in srgb, var(--text) 25%, transparent);
|
||||||
|
border-top-color: var(--text);
|
||||||
|
animation: sync-spin .8s linear infinite;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
@keyframes sync-spin { to { transform: rotate(360deg); } }
|
||||||
|
.sync-btn-pct {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 2.6ch;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.sync-btn.is-failed { border-color: var(--danger); }
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.sync-btn-spinner { animation: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.email-preview { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 18px; white-space: pre-wrap; font-size: 13.5px; line-height: 1.7; color: var(--text-2); }
|
.email-preview { background: var(--bg-sunken); border: 1px solid var(--border); border-radius: 12px; padding: 18px; white-space: pre-wrap; font-size: 13.5px; line-height: 1.7; color: var(--text-2); }
|
||||||
.attach-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev); }
|
.attach-card { display: flex; align-items: center; gap: 12px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-elev); }
|
||||||
.attach-icn { width: 42px; height: 42px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); display: grid; place-items: center; }
|
.attach-icn { width: 42px; height: 42px; border-radius: 10px; background: var(--danger-soft); color: var(--danger); display: grid; place-items: center; }
|
||||||
|
|
@ -1483,7 +1508,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.page-title { font-size: var(--fs-2xl); }
|
.page-title { font-size: var(--fs-2xl); }
|
||||||
.page-head { gap: 12px; margin-bottom: 18px; }
|
.page-head { gap: 12px; margin-bottom: 18px; }
|
||||||
.page-head-actions { width: 100%; }
|
.page-head-actions { width: 100%; }
|
||||||
.page-head-actions .btn { flex: 1 1 auto; justify-content: center; }
|
.page-head-actions .btn,
|
||||||
|
.page-head-actions .sync-btn { flex: 1 1 auto; justify-content: center; }
|
||||||
|
|
||||||
/* The action cluster was eating 232px of a 375px bar, leaving search
|
/* The action cluster was eating 232px of a 375px bar, leaving search
|
||||||
~59px — too narrow to read or tap. Drop the secondary affordances
|
~59px — too narrow to read or tap. Drop the secondary affordances
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/* ============================================================
|
||||||
|
SyncButton.jsx — mailbox Sync control.
|
||||||
|
|
||||||
|
Idle is a plain pill. After click, the label follows the worker's
|
||||||
|
triage.processed / triage.expected. 100% only when the run completes.
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
import { Icon } from './primitives'
|
||||||
|
|
||||||
|
export function mailboxSyncPercent(run) {
|
||||||
|
if (!run) return null
|
||||||
|
if (run.status === 'completed') return 100
|
||||||
|
if (run.status === 'failed') return null
|
||||||
|
const expected = Number(run.triage?.expected)
|
||||||
|
const processed = Number(run.triage?.processed)
|
||||||
|
const nExpected = Number.isFinite(expected) && expected > 0 ? expected : 0
|
||||||
|
const nProcessed = Number.isFinite(processed)
|
||||||
|
? processed
|
||||||
|
: (Array.isArray(run.entries) ? run.entries.length : 0)
|
||||||
|
if (nExpected <= 0) return null
|
||||||
|
return Math.min(99, Math.round((nProcessed / nExpected) * 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyncButton({ run, pending, disabled, onClick }) {
|
||||||
|
const status = run?.status
|
||||||
|
const busy = pending || status === 'queued' || status === 'running'
|
||||||
|
const done = status === 'completed'
|
||||||
|
const failed = status === 'failed'
|
||||||
|
const pct = mailboxSyncPercent(run)
|
||||||
|
const label = failed
|
||||||
|
? 'Sync failed'
|
||||||
|
: done
|
||||||
|
? 'Synced'
|
||||||
|
: busy
|
||||||
|
? 'Syncing'
|
||||||
|
: 'Sync'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn-secondary sync-btn${done ? ' done' : ''}${failed ? ' is-failed' : ''}`}
|
||||||
|
disabled={busy || disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
title={disabled && !busy ? 'Requires inbox.edit' : undefined}
|
||||||
|
aria-label={pct != null ? `${label} ${pct}%` : label}
|
||||||
|
>
|
||||||
|
{busy ? <span className="sync-btn-spinner" aria-hidden="true" /> : <Icon name="refresh" />}
|
||||||
|
<span>{label}</span>
|
||||||
|
{pct != null && <span className="sync-btn-pct">{pct}%</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue