Merge pull request 'Add_Flush' (#41) from Add_Flush into main
Deploy to S3 / deploy (push) Successful in 33s Details

Reviewed-on: #41
pull/42/head
ahmed.mujtaba 2026-08-31 12:37:25 +00:00
commit f8a67e7649
5 changed files with 190 additions and 3 deletions

View File

@ -69,9 +69,26 @@ async def sync_mailbox(run_id:str) -> dict:
service=Email(session=session)
if not service.token:
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:
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:
logger.exception("mailbox sync failed for run %s",run_id)

View File

@ -356,13 +356,18 @@ class Email:
raise HTTPException(status_code=404,detail="No sync runs yet")
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.
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)
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])
entries=[]
for item in value:
@ -398,6 +403,8 @@ class Email:
"triage_status":"error",
})
self.triage_errors.append(str(message_id))
if on_progress:
await on_progress(len(entries),expected,entries)
if self.pending_match_ids:
await self.enqueue_matching(list(self.pending_match_ids),force=False)

View File

@ -15,6 +15,7 @@ import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import OpenResumeButton from '../ui/OpenResumeButton'
import PageHeader from '../ui/PageHeader'
import SyncButton from '../ui/SyncButton'
import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
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. */
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. */
const CHANNELS = [
{ 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 })
}
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 (
<div className="page">
<PageHeader
@ -989,6 +1066,14 @@ export default function Inbox() {
{isForms && (
<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')}>
<Icon name="upload" /> Upload CVs
</button>

View File

@ -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; }
@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); }
.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; }
@ -1463,7 +1488,8 @@ canvas { width: 100%; max-width: 100%; display: block; }
.page-title { font-size: var(--fs-2xl); }
.page-head { gap: 12px; margin-bottom: 18px; }
.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
~59px too narrow to read or tap. Drop the secondary affordances

View File

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