Jobber/orchestrator/src/client/pages/TrackingInboxPage.tsx
Shaheer Sarfaraz f3c164d252
feat(pipeline): parallelize discovery/process via evolved asyncPool (#211)
* feat(pipeline): centralize concurrency hooks and parallelize discovery/process steps

* feat(orchestrator): unify single and bulk job actions API

* job actions de-bulk-ified

* application inbox section debulk

* chore(orchestrator): remove remaining bulk wording from job action flow

* select multiple to skip with shortcut

* comments

* coomeents

* fix progress ordinal and add jobs actions payload examples
2026-02-20 16:49:13 +00:00

880 lines
29 KiB
TypeScript

import type {
JobListItem,
PostApplicationInboxItem,
PostApplicationProvider,
PostApplicationSyncRun,
} from "@shared/types";
import { POST_APPLICATION_PROVIDERS } from "@shared/types";
import { useQuery } from "@tanstack/react-query";
import {
CheckCircle,
Inbox,
Link2,
Loader2,
RefreshCcw,
Unplug,
Upload,
XCircle,
} from "lucide-react";
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { useQueryErrorToast } from "@/client/hooks/useQueryErrorToast";
import { queryKeys } from "@/client/lib/queryKeys";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { formatDateTime } from "@/lib/utils";
import * as api from "../api";
import { EmptyState, PageHeader, PageMain } from "../components";
import { EmailViewerList } from "./tracking-inbox/EmailViewerList";
const PROVIDER_OPTIONS: PostApplicationProvider[] = [
...POST_APPLICATION_PROVIDERS,
];
const GMAIL_OAUTH_RESULT_TYPE = "gmail-oauth-result";
const GMAIL_OAUTH_TIMEOUT_MS = 3 * 60 * 1000;
const EMPTY_INBOX_ITEMS: PostApplicationInboxItem[] = [];
const EMPTY_SYNC_RUNS: PostApplicationSyncRun[] = [];
type GmailOauthResultMessage = {
type: string;
state?: string;
code?: string;
error?: string;
};
function formatEpochMs(value?: number | null): string {
if (!value) return "n/a";
return formatDateTime(new Date(value).toISOString()) ?? "n/a";
}
export const TrackingInboxPage: React.FC = () => {
const [provider, setProvider] = useState<PostApplicationProvider>("gmail");
const [accountKey, setAccountKey] = useState("default");
const [maxMessages, setMaxMessages] = useState("100");
const [searchDays, setSearchDays] = useState("90");
const [isRefreshing, setIsRefreshing] = useState(false);
const [isActionLoading, setIsActionLoading] = useState(false);
const [activeAction, setActiveAction] = useState<
"connect" | "sync" | "disconnect" | null
>(null);
const [isRunModalOpen, setIsRunModalOpen] = useState(false);
const [selectedRun, setSelectedRun] = useState<PostApplicationSyncRun | null>(
null,
);
const [appliedJobByMessageId, setAppliedJobByMessageId] = useState<
Record<string, string>
>({});
const statusQuery = useQuery({
queryKey: queryKeys.postApplication.providerStatus(provider, accountKey),
queryFn: () => api.postApplicationProviderStatus({ provider, accountKey }),
enabled: Boolean(provider && accountKey),
});
const inboxQuery = useQuery({
queryKey: queryKeys.postApplication.inbox(provider, accountKey, 100),
queryFn: () =>
api.getPostApplicationInbox({ provider, accountKey, limit: 100 }),
enabled: Boolean(provider && accountKey),
});
const runsQuery = useQuery({
queryKey: queryKeys.postApplication.runs(provider, accountKey, 20),
queryFn: () =>
api.getPostApplicationRuns({ provider, accountKey, limit: 20 }),
enabled: Boolean(provider && accountKey),
});
const status = statusQuery.data?.status ?? null;
const inbox = inboxQuery.data?.items ?? EMPTY_INBOX_ITEMS;
const runs = runsQuery.data?.runs ?? EMPTY_SYNC_RUNS;
const runMessagesQuery = useQuery({
queryKey: queryKeys.postApplication.runMessages(
selectedRun?.id ?? "",
provider,
accountKey,
),
queryFn: () =>
api.getPostApplicationRunMessages({
runId: selectedRun?.id ?? "",
provider,
accountKey,
}),
enabled: Boolean(
isRunModalOpen && selectedRun?.id && provider && accountKey,
),
});
const selectedRunItems = runMessagesQuery.data?.items ?? EMPTY_INBOX_ITEMS;
const isRunMessagesLoading =
runMessagesQuery.isPending || runMessagesQuery.isFetching;
const hasReviewItems = useMemo(
() => inbox.length > 0 || selectedRunItems.length > 0,
[inbox.length, selectedRunItems.length],
);
const appliedJobsQuery = useQuery({
queryKey: queryKeys.jobs.list({
statuses: ["applied", "in_progress"],
view: "list",
}),
queryFn: () =>
api.getJobs({
statuses: ["applied", "in_progress"],
view: "list",
}),
enabled: hasReviewItems,
});
const appliedJobs = useMemo<JobListItem[]>(
() =>
(appliedJobsQuery.data?.jobs ?? []).filter(
(job) => job.status === "applied" || job.status === "in_progress",
),
[appliedJobsQuery.data?.jobs],
);
const isAppliedJobsLoading =
appliedJobsQuery.isPending || appliedJobsQuery.isFetching;
const [inboxActionDialog, setInboxActionDialog] = useState<{
isOpen: boolean;
action: "approve" | "deny" | null;
itemCount: number;
}>({ isOpen: false, action: null, itemCount: 0 });
const isLoading =
statusQuery.isPending || inboxQuery.isPending || runsQuery.isPending;
const refresh = useCallback(async () => {
setIsRefreshing(true);
try {
await Promise.all([
statusQuery.refetch(),
inboxQuery.refetch(),
runsQuery.refetch(),
hasReviewItems ? appliedJobsQuery.refetch() : Promise.resolve(),
]);
} catch (error) {
const message =
error instanceof Error
? error.message
: "Failed to refresh tracking inbox";
toast.error(message);
} finally {
setIsRefreshing(false);
}
}, [appliedJobsQuery, hasReviewItems, inboxQuery, runsQuery, statusQuery]);
useEffect(() => {
if (!provider || !accountKey) return;
setAppliedJobByMessageId({});
}, [provider, accountKey]);
useEffect(() => {
const defaultAppliedJobId = appliedJobs[0]?.id ?? "";
setAppliedJobByMessageId((previous) => {
const next = { ...previous };
let didChange = false;
for (const item of [...inbox, ...selectedRunItems]) {
const selectedJobId = next[item.message.id];
const hasValidSelection = appliedJobs.some(
(appliedJob) => appliedJob.id === selectedJobId,
);
if (!selectedJobId || !hasValidSelection) {
const matchedJobId = item.message.matchedJobId ?? "";
const hasValidMatchedJob = appliedJobs.some(
(appliedJob) => appliedJob.id === matchedJobId,
);
const nextJobId = hasValidMatchedJob
? matchedJobId
: defaultAppliedJobId;
if (next[item.message.id] !== nextJobId) {
next[item.message.id] = nextJobId;
didChange = true;
}
}
}
return didChange ? next : previous;
});
}, [appliedJobs, inbox, selectedRunItems]);
const waitForGmailOauthResult = useCallback(
(
expectedState: string,
popup: Window,
): Promise<{ code?: string; error?: string }> => {
return new Promise((resolve, reject) => {
let settled = false;
const close = () => {
window.clearTimeout(timeoutId);
window.clearInterval(closedCheckId);
window.removeEventListener("message", onMessage);
};
const finishResolve = (value: { code?: string; error?: string }) => {
if (settled) return;
settled = true;
close();
try {
popup.close();
} catch {
// Ignore cross-window close errors.
}
resolve(value);
};
const finishReject = (message: string) => {
if (settled) return;
settled = true;
close();
reject(new Error(message));
};
const onMessage = (event: MessageEvent<unknown>) => {
if (event.origin !== window.location.origin) return;
const data = event.data as GmailOauthResultMessage | undefined;
if (!data || data.type !== GMAIL_OAUTH_RESULT_TYPE) return;
if (data.state !== expectedState) return;
finishResolve({
...(data.code ? { code: data.code } : {}),
...(data.error ? { error: data.error } : {}),
});
};
const timeoutId = window.setTimeout(() => {
finishReject("Timed out waiting for Gmail OAuth response.");
}, GMAIL_OAUTH_TIMEOUT_MS);
const closedCheckId = window.setInterval(() => {
if (!popup.closed) return;
finishReject("Gmail OAuth window was closed before completion.");
}, 250);
window.addEventListener("message", onMessage);
});
},
[],
);
const runProviderAction = useCallback(
async (action: "connect" | "sync" | "disconnect") => {
setIsActionLoading(true);
setActiveAction(action);
let syncToastId: string | number | null = null;
try {
if (action === "connect") {
if (provider !== "gmail") {
toast.error(
`${provider} connect is not implemented yet. Use Gmail for now.`,
);
return;
}
const oauthStart = await api.postApplicationGmailOauthStart({
accountKey,
});
const popup = window.open(
oauthStart.authorizationUrl,
"gmail-oauth-connect",
"popup,width=520,height=720",
);
if (!popup) {
toast.error(
"Browser blocked the Gmail OAuth popup. Allow popups and retry.",
);
return;
}
const oauthResult = await waitForGmailOauthResult(
oauthStart.state,
popup,
);
if (oauthResult.error) {
throw new Error(`Gmail OAuth failed: ${oauthResult.error}`);
}
if (!oauthResult.code) {
throw new Error(
"Gmail OAuth did not return an authorization code.",
);
}
await api.postApplicationGmailOauthExchange({
accountKey,
state: oauthStart.state,
code: oauthResult.code,
});
toast.success("Provider connected");
} else if (action === "sync") {
const parsedMaxMessages = Number.parseInt(maxMessages, 10);
const parsedSearchDays = Number.parseInt(searchDays, 10);
if (
!Number.isFinite(parsedMaxMessages) ||
parsedMaxMessages < 1 ||
parsedMaxMessages > 500 ||
!Number.isFinite(parsedSearchDays) ||
parsedSearchDays < 1 ||
parsedSearchDays > 365
) {
toast.error(
"Max messages must be 1-500 and search days must be 1-365 before syncing.",
);
return;
}
syncToastId = toast.loading(
"Sync in progress. This may take up to a couple of minutes.",
);
await api.postApplicationProviderSync({
provider,
accountKey,
maxMessages: parsedMaxMessages,
searchDays: parsedSearchDays,
});
toast.success("Sync completed", {
...(syncToastId ? { id: syncToastId } : {}),
});
} else {
await api.postApplicationProviderDisconnect({ provider, accountKey });
toast.success("Provider disconnected");
}
await refresh();
} catch (error) {
const message =
error instanceof Error
? error.message
: `Failed to ${action} provider connection`;
if (syncToastId) {
toast.error(message, { id: syncToastId });
} else {
toast.error(message);
}
} finally {
setActiveAction(null);
setIsActionLoading(false);
}
},
[
accountKey,
maxMessages,
provider,
refresh,
searchDays,
waitForGmailOauthResult,
],
);
const handleDecision = useCallback(
async (item: PostApplicationInboxItem, decision: "approve" | "deny") => {
const selectedJobId =
appliedJobByMessageId[item.message.id] || item.message.matchedJobId;
if (decision === "approve" && !selectedJobId) {
toast.error("Select an applied job before making a decision.");
return;
}
setIsActionLoading(true);
try {
if (decision === "approve") {
await api.approvePostApplicationInboxItem({
messageId: item.message.id,
provider,
accountKey,
jobId: selectedJobId ?? undefined,
stageTarget: item.message.stageTarget ?? undefined,
});
toast.success("Message linked");
} else {
await api.denyPostApplicationInboxItem({
messageId: item.message.id,
provider,
accountKey,
});
toast.success("Message ignored");
}
await refresh();
} catch (error) {
const message =
error instanceof Error
? error.message
: `Failed to ${decision} message`;
toast.error(message);
} finally {
setIsActionLoading(false);
}
},
[accountKey, appliedJobByMessageId, provider, refresh],
);
const handleInboxAction = useCallback(
async (action: "approve" | "deny") => {
if (inbox.length === 0) return;
setIsActionLoading(true);
setInboxActionDialog({ isOpen: false, action: null, itemCount: 0 });
try {
const result = await api.runPostApplicationInboxAction({
action,
provider,
accountKey,
});
const { succeeded, failed, skipped } = result;
const actionLabel = action === "approve" ? "approved" : "ignored";
if (failed === 0 && skipped === 0) {
toast.success(`All ${succeeded} messages ${actionLabel}`);
} else if (failed === 0) {
toast.success(
`${succeeded} messages ${actionLabel}, ${skipped} skipped (no suggested match)`,
);
} else {
toast.error(
`${succeeded} ${actionLabel}, ${failed} failed, ${skipped} skipped`,
);
}
await refresh();
} catch (error) {
const message =
error instanceof Error
? error.message
: `Failed to ${action} messages`;
toast.error(message);
} finally {
setIsActionLoading(false);
}
},
[accountKey, inbox.length, provider, refresh],
);
const openInboxActionDialog = useCallback(
(action: "approve" | "deny") => {
const eligibleCount =
action === "approve"
? inbox.filter((item) => item.matchedJob).length
: inbox.length;
if (eligibleCount === 0) {
toast.error(
action === "approve"
? "No messages with suggested job matches to approve"
: "No messages to ignore",
);
return;
}
setInboxActionDialog({
isOpen: true,
action,
itemCount: eligibleCount,
});
},
[inbox],
);
const handleOpenRunMessages = useCallback((run: PostApplicationSyncRun) => {
setSelectedRun(run);
setIsRunModalOpen(true);
}, []);
useQueryErrorToast(
statusQuery.error,
"Failed to load provider connection status",
);
useQueryErrorToast(inboxQuery.error, "Failed to load inbox");
useQueryErrorToast(runsQuery.error, "Failed to load sync runs");
useQueryErrorToast(
appliedJobsQuery.error,
"Failed to load jobs for inbox matching",
);
useQueryErrorToast(
runMessagesQuery.error,
"Failed to load messages for selected sync run",
);
const pendingCount = inbox.length;
const isConnected = Boolean(status?.connected);
const connectionLabel = useMemo(() => {
if (!status) return "Unknown";
if (!status.connected) return "Disconnected";
if (status.integration?.status === "error") return "Error";
return "Connected";
}, [status]);
const handleAppliedJobChange = useCallback(
(messageId: string, value: string) => {
setAppliedJobByMessageId((previous) => ({
...previous,
[messageId]: value,
}));
},
[],
);
return (
<>
<PageHeader
icon={Inbox}
title="Tracking Inbox"
subtitle="Post-application message review"
actions={
<Button
size="sm"
variant="outline"
onClick={() => void refresh()}
disabled={isRefreshing || isLoading}
className="gap-2"
>
{isRefreshing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
Refresh
</Button>
}
/>
<PageMain className="space-y-4">
<section className="space-y-1 px-1">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">
Application Inbox Matching
</h1>
</div>
<p className="text-sm text-muted-foreground">
Connect your inbox to ingest related emails, review the suggested
job matches, and approve or deny to automatically update your
tracking timeline.
</p>
</section>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Provider Controls</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="provider">Provider</Label>
<Select
value={provider}
onValueChange={(value) =>
setProvider(value as PostApplicationProvider)
}
>
<SelectTrigger id="provider">
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="accountKey">Account Key</Label>
<Input
id="accountKey"
value={accountKey}
onChange={(event) => setAccountKey(event.target.value)}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
Gmail connect uses Google OAuth popup and stores credentials
server-side. No manual refresh token paste is needed.
</p>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="maxMessages">Max Messages</Label>
<Input
id="maxMessages"
inputMode="numeric"
value={maxMessages}
onChange={(event) => setMaxMessages(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="searchDays">Search Days</Label>
<Input
id="searchDays"
inputMode="numeric"
value={searchDays}
onChange={(event) => setSearchDays(event.target.value)}
/>
</div>
<div className="md:col-span-2 flex flex-wrap items-end gap-2">
{!isConnected ? (
<Button
onClick={() => void runProviderAction("connect")}
disabled={isActionLoading}
className="gap-2"
>
<Link2 className="h-4 w-4" />
Connect
</Button>
) : null}
<Button
onClick={() => void runProviderAction("sync")}
disabled={isActionLoading || !isConnected}
variant="secondary"
className="gap-2"
>
{activeAction === "sync" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{activeAction === "sync" ? "Syncing..." : "Sync"}
</Button>
{isConnected ? (
<Button
onClick={() => void runProviderAction("disconnect")}
disabled={isActionLoading}
variant="outline"
className="gap-2"
>
<Unplug className="h-4 w-4" />
Disconnect
</Button>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<Badge variant={status?.connected ? "default" : "outline"}>
{connectionLabel}
</Badge>
<span className="text-muted-foreground">
Pending review:{" "}
<span className="font-semibold">{pendingCount}</span>
</span>
{status?.integration?.lastSyncedAt ? (
<span className="text-muted-foreground">
Last synced: {formatEpochMs(status.integration.lastSyncedAt)}
</span>
) : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-3">
<CardTitle className="text-base">Pending Review Queue</CardTitle>
{inbox.length > 0 && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={isActionLoading}
onClick={() => openInboxActionDialog("approve")}
>
<CheckCircle className="h-4 w-4" />
Approve All
</Button>
<Button
variant="outline"
size="sm"
className="gap-1"
disabled={isActionLoading}
onClick={() => openInboxActionDialog("deny")}
>
<XCircle className="h-4 w-4" />
Ignore All
</Button>
</div>
)}
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading inbox...
</div>
) : inbox.length === 0 ? (
<EmptyState
title="No pending messages"
description="Run sync to ingest new job-application emails."
/>
) : (
<EmailViewerList
items={inbox}
appliedJobs={appliedJobs}
appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) =>
void handleDecision(item, decision)
}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}
/>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sync Runs</CardTitle>
</CardHeader>
<CardContent>
{runs.length === 0 ? (
<p className="text-sm text-muted-foreground">No sync runs yet.</p>
) : (
<div className="space-y-2">
{runs.map((run) => (
<button
key={run.id}
type="button"
className="w-full rounded-lg border px-3 py-2 text-left transition-colors hover:bg-muted/30"
onClick={() => void handleOpenRunMessages(run)}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
<p>{run.id}</p>
<p>{formatEpochMs(run.startedAt)}</p>
</div>
<div className="flex items-center gap-2 text-xs">
<Badge variant="outline">{run.status}</Badge>
<span className="text-muted-foreground">
discovered {run.messagesDiscovered}
</span>
<span className="text-muted-foreground">
relevant {run.messagesRelevant}
</span>
<span className="text-muted-foreground">
matched {run.messagesMatched}
</span>
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
</PageMain>
<Dialog
open={isRunModalOpen}
onOpenChange={(open) => {
setIsRunModalOpen(open);
if (!open) {
setSelectedRun(null);
}
}}
>
<DialogContent className="max-h-[85vh] max-w-6xl overflow-hidden p-0">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Run Messages</DialogTitle>
<DialogDescription>
{selectedRun
? `Run ${selectedRun.id} • discovered ${selectedRun.messagesDiscovered} • relevant ${selectedRun.messagesRelevant} • matched ${selectedRun.messagesMatched}`
: "Review all messages captured in this sync run, including partial matches."}
</DialogDescription>
</DialogHeader>
<div className="max-h-[calc(85vh-92px)] overflow-auto px-6 pb-6">
{isRunMessagesLoading ? (
<div className="flex items-center gap-2 py-4 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading run messages...
</div>
) : selectedRunItems.length === 0 ? (
<p className="py-4 text-sm text-muted-foreground">
No messages found for this run.
</p>
) : (
<EmailViewerList
items={selectedRunItems}
appliedJobs={appliedJobs}
appliedJobByMessageId={appliedJobByMessageId}
onAppliedJobChange={handleAppliedJobChange}
onDecision={(item, decision) =>
void handleDecision(item, decision)
}
isActionLoading={isActionLoading}
isAppliedJobsLoading={isAppliedJobsLoading}
/>
)}
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={inboxActionDialog.isOpen}
onOpenChange={(open) =>
setInboxActionDialog((previous) => ({ ...previous, isOpen: open }))
}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{inboxActionDialog.action === "approve"
? "Approve All Messages?"
: "Ignore All Messages?"}
</AlertDialogTitle>
<AlertDialogDescription>
{inboxActionDialog.action === "approve"
? `This will approve ${inboxActionDialog.itemCount} message${inboxActionDialog.itemCount === 1 ? "" : "s"} with suggested job matches. Messages without matches will be skipped.`
: `This will ignore all ${inboxActionDialog.itemCount} pending message${inboxActionDialog.itemCount === 1 ? "" : "s"}.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (inboxActionDialog.action) {
void handleInboxAction(inboxActionDialog.action);
}
}}
>
{inboxActionDialog.action === "approve"
? "Approve All"
: "Ignore All"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};