Complete S01 Gmail matching and import workflow
This commit is contained in:
@@ -25,11 +25,19 @@ import { alpha, useTheme } from "@mui/material/styles";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { IconButton } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
||||
import {
|
||||
CorrespondenceMessage,
|
||||
GmailImportMessageResult,
|
||||
GmailImportThreadResult,
|
||||
GmailJobMatchesResponse,
|
||||
GmailStatus,
|
||||
JobApplication,
|
||||
} from "../types";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -63,22 +71,32 @@ function parseRawEmail(raw: string): { subject?: string; date?: string; from?: s
|
||||
return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body };
|
||||
}
|
||||
|
||||
function scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) {
|
||||
const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let score = 0;
|
||||
if (q && hay.includes(q)) score += 8;
|
||||
if (/interview|application|recruit|follow up|follow-up|position|role/.test(hay)) score += 2;
|
||||
if (messages.some((m) => m.subject && message.subject && m.subject.toLowerCase() === message.subject.toLowerCase())) score -= 4;
|
||||
if (message.date) {
|
||||
const ageDays = Math.abs((Date.now() - new Date(message.date).getTime()) / 86400000);
|
||||
if (ageDays <= 30) score += 3;
|
||||
else if (ageDays <= 120) score += 1;
|
||||
}
|
||||
return score;
|
||||
function formatConfidence(value: string) {
|
||||
return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
|
||||
}
|
||||
|
||||
export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
function formatReasonLabel(label: string) {
|
||||
switch (label) {
|
||||
case "company":
|
||||
return "Company";
|
||||
case "recruiterEmail":
|
||||
return "Recruiter email";
|
||||
case "recruiter":
|
||||
return "Recruiter";
|
||||
case "jobTitle":
|
||||
return "Job title";
|
||||
case "existingSubject":
|
||||
return "Existing subject";
|
||||
case "recency":
|
||||
return "Recent";
|
||||
case "status":
|
||||
return "Status";
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -95,8 +113,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||
const [gmailLoading, setGmailLoading] = useState(false);
|
||||
const [gmailQuery, setGmailQuery] = useState("");
|
||||
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
|
||||
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
|
||||
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
|
||||
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||
|
||||
@@ -117,22 +135,23 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadGmailMessages = useCallback(async () => {
|
||||
const loadGmailMatches = useCallback(async (queryOverride?: string) => {
|
||||
try {
|
||||
setGmailMessagesLoading(true);
|
||||
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
|
||||
setGmailMatchesLoading(true);
|
||||
const res = await api.get<GmailJobMatchesResponse>("/gmail/job-candidates", {
|
||||
params: {
|
||||
query: gmailQuery.trim() || undefined,
|
||||
maxResults: 12,
|
||||
jobApplicationId: jobId,
|
||||
queryOverride: queryOverride?.trim() || undefined,
|
||||
maxResultsPerQuery: 6,
|
||||
},
|
||||
});
|
||||
setGmailMessages(res.data);
|
||||
setGmailMatches(res.data);
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error");
|
||||
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
|
||||
} finally {
|
||||
setGmailMessagesLoading(false);
|
||||
setGmailMatchesLoading(false);
|
||||
}
|
||||
}, [gmailQuery, toast]);
|
||||
}, [jobId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -151,8 +170,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
||||
void loadGmailMessages();
|
||||
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
|
||||
void loadGmailMatches(gmailQuery);
|
||||
}, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
@@ -162,7 +181,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
toast(data.message || t("googleLinkedSuccess"), "success");
|
||||
void loadGmailStatus();
|
||||
setImportTab(1);
|
||||
void loadGmailMessages();
|
||||
void loadGmailMatches(gmailQuery);
|
||||
} else {
|
||||
toast(data.message || t("googleAuthFailed"), "error");
|
||||
}
|
||||
@@ -170,36 +189,24 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [loadGmailMessages, loadGmailStatus, t, toast]);
|
||||
}, [gmailQuery, loadGmailMatches, loadGmailStatus, t, toast]);
|
||||
|
||||
const canSend = useMemo(() => text.trim().length > 0, [text]);
|
||||
|
||||
const suggestedQueries = useMemo(() => {
|
||||
const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[];
|
||||
const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2);
|
||||
const companyTerms = Array.from(new Set(messages.filter((m) => m.from === "Company").map((m) => m.subject).filter(Boolean) as string[])).slice(0, 2);
|
||||
const fromSubjects = messages.map((m) => m.subject).filter(Boolean) as string[];
|
||||
const uniqueSubjects = Array.from(new Set(fromSubjects)).slice(0, 2);
|
||||
const companyName = job?.company?.name?.trim();
|
||||
const recruiterEmail = job?.company?.recruiterEmail?.trim();
|
||||
const jobTitle = job?.jobTitle?.trim();
|
||||
|
||||
return [
|
||||
{ label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" },
|
||||
{ label: "Inbox only", value: "label:inbox newer_than:120d" },
|
||||
{ label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" },
|
||||
...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })),
|
||||
...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })),
|
||||
].slice(0, 6);
|
||||
}, [messages]);
|
||||
|
||||
const rankedMessages = useMemo(() => {
|
||||
return [...gmailMessages].sort((a, b) => scoreMessage(b, gmailQuery, messages) - scoreMessage(a, gmailQuery, messages));
|
||||
}, [gmailMessages, gmailQuery, messages]);
|
||||
|
||||
const groupedByThread = useMemo(() => {
|
||||
const map = new Map<string, GmailMessageSummary[]>();
|
||||
for (const message of rankedMessages) {
|
||||
const key = message.threadId || message.id;
|
||||
map.set(key, [...(map.get(key) ?? []), message]);
|
||||
}
|
||||
return Array.from(map.entries()).map(([threadId, items]) => ({ threadId, items }));
|
||||
}, [rankedMessages]);
|
||||
recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
|
||||
companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
|
||||
companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
|
||||
...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
|
||||
].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>;
|
||||
}, [job?.company?.name, job?.company?.recruiterEmail, job?.jobTitle, messages]);
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
@@ -227,6 +234,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
subject: parsed.subject ?? null,
|
||||
content: parsed.body || rawEmail,
|
||||
date: parsed.date ?? null,
|
||||
externalFrom: parsed.from ?? null,
|
||||
externalTo: parsed.to ?? null,
|
||||
});
|
||||
setImportOpen(false);
|
||||
setRawEmail("");
|
||||
@@ -251,7 +260,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
try {
|
||||
await api.delete("/gmail/connection");
|
||||
setGmailStatus({ connected: false });
|
||||
setGmailMessages([]);
|
||||
setGmailMatches(null);
|
||||
toast(t("googleUnlinked"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
||||
@@ -272,9 +281,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const importGmailMessage = async (messageId: string) => {
|
||||
try {
|
||||
setImportingMessageId(messageId);
|
||||
await api.post("/gmail/import", { jobApplicationId: jobId, messageId });
|
||||
const res = await api.post<GmailImportMessageResult>("/gmail/import", { jobApplicationId: jobId, messageId });
|
||||
await load();
|
||||
toast(t("correspondenceImportEmail"), "success");
|
||||
await loadGmailMatches(gmailQuery);
|
||||
if (res.data.imported > 0) {
|
||||
toast(t("correspondenceImportEmail"), "success");
|
||||
} else {
|
||||
toast("This Gmail message is already linked to the job.", "success");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
|
||||
} finally {
|
||||
@@ -285,8 +299,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const importGmailThread = async (threadId: string, messageIds: string[]) => {
|
||||
try {
|
||||
setImportingThreadId(threadId);
|
||||
const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
||||
const res = await api.post<GmailImportThreadResult>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
||||
await load();
|
||||
await loadGmailMatches(gmailQuery);
|
||||
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
|
||||
@@ -310,6 +325,13 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
|
||||
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
||||
{(m.externalThreadId || m.externalFrom || m.externalTo) ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
||||
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
|
||||
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
|
||||
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
||||
@@ -360,11 +382,16 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
|
||||
</Typography>
|
||||
{job ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
Matching against {job.company?.name || "this company"} / {job.jobTitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{gmailStatus?.connected ? (
|
||||
<>
|
||||
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceRefresh")}</Button>
|
||||
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
|
||||
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -382,36 +409,65 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
|
||||
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button>
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceSearch")}</Button>
|
||||
</Box>
|
||||
{gmailMatches?.queries?.length ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{gmailMatches.queries.slice(0, 4).map((query) => (
|
||||
<Chip key={query} size="small" label={query} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
||||
{gmailMessagesLoading ? (
|
||||
{gmailMatchesLoading ? (
|
||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||
) : groupedByThread.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography>
|
||||
) : !gmailMatches || gmailMatches.threads.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", p: 2 }}>
|
||||
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
|
||||
</Typography>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{groupedByThread.map(({ threadId, items }, threadIndex) => (
|
||||
<React.Fragment key={threadId}>
|
||||
{gmailMatches.threads.map((thread, threadIndex) => (
|
||||
<React.Fragment key={thread.threadId}>
|
||||
{threadIndex > 0 ? <Divider /> : null}
|
||||
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</Typography>
|
||||
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}>
|
||||
<Chip size="small" label={`${formatConfidence(thread.confidence)} · score ${thread.score}`} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
|
||||
<Chip size="small" label={`${thread.messageCount} message${thread.messageCount === 1 ? "" : "s"}`} variant="outlined" />
|
||||
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||
{thread.matchReasons.map((reason) => (
|
||||
<Chip key={`${thread.threadId}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
|
||||
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === thread.threadId} onClick={() => void importGmailThread(thread.threadId, thread.messages.map((x) => x.id))}>
|
||||
{importingThreadId === thread.threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
||||
</Button>
|
||||
</Box>
|
||||
{items.map((message, index) => (
|
||||
{thread.messages.map((message, index) => (
|
||||
<React.Fragment key={message.id}>
|
||||
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
||||
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
|
||||
<ListItemText
|
||||
secondaryTypographyProps={{ component: "div" }}
|
||||
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
|
||||
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
|
||||
secondary={<Box component="span" sx={{ mt: 0.5, display: "block" }}>
|
||||
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
|
||||
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
|
||||
<Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" />
|
||||
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null}
|
||||
{message.matchReasons.map((reason) => (
|
||||
<Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>}
|
||||
/>
|
||||
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
|
||||
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
|
||||
|
||||
@@ -357,7 +357,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
|
||||
{tab === 1 && jobId && <Correspondence jobId={jobId} job={job} />}
|
||||
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
||||
|
||||
{tab === 3 && (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import JobDetailsDialog from "./components/JobDetailsDialog";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={1} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("correspondence Gmail import", () => {
|
||||
let correspondenceMessages: any[];
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
window.open = jest.fn();
|
||||
|
||||
correspondenceMessages = [];
|
||||
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === "/jobapplications/42") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
id: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
status: "Applied",
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 3,
|
||||
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
||||
tailoredCvText: "",
|
||||
shortSummary: "summary",
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/auth/me") {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: "Master CV text" } } as any);
|
||||
}
|
||||
if (url === "/jobapplications/42/history") {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
}
|
||||
if (url === "/attachments/42") {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
}
|
||||
if (url === "/correspondence/42") {
|
||||
return Promise.resolve({ data: correspondenceMessages } as any);
|
||||
}
|
||||
if (url === "/gmail/status") {
|
||||
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString() } } as any);
|
||||
}
|
||||
if (url === "/gmail/job-candidates") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
jobApplicationId: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
companyName: "Acme",
|
||||
recruiterName: "Maria Recruiter",
|
||||
recruiterEmail: "maria@acme.test",
|
||||
queries: [config?.params?.queryOverride || '"Acme" "Backend Developer" newer_than:365d'],
|
||||
threads: [
|
||||
{
|
||||
threadId: "thread-1",
|
||||
subject: "Backend Developer interview",
|
||||
score: 42,
|
||||
confidence: "high",
|
||||
hasImportedMessages: false,
|
||||
messageCount: 2,
|
||||
latestDate: new Date().toISOString(),
|
||||
matchReasons: [
|
||||
{ label: "company", value: "Acme" },
|
||||
{ label: "recruiterEmail", value: "maria@acme.test" },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: "msg-1",
|
||||
threadId: "thread-1",
|
||||
subject: "Backend Developer interview",
|
||||
from: "Maria Recruiter <maria@acme.test>",
|
||||
to: "user@example.test",
|
||||
date: new Date().toISOString(),
|
||||
snippet: "Acme wants to schedule a call.",
|
||||
score: 42,
|
||||
confidence: "high",
|
||||
alreadyImported: false,
|
||||
matchReasons: [
|
||||
{ label: "company", value: "Acme" },
|
||||
{ label: "jobTitle", value: "Developer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
threadId: "thread-1",
|
||||
subject: "Backend Developer follow-up",
|
||||
from: "user@example.test",
|
||||
to: "Maria Recruiter <maria@acme.test>",
|
||||
date: new Date().toISOString(),
|
||||
snippet: "Following up on the role.",
|
||||
score: 24,
|
||||
confidence: "medium",
|
||||
alreadyImported: false,
|
||||
matchReasons: [{ label: "recency", value: "45d" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
|
||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||
if (url === "/gmail/import") {
|
||||
correspondenceMessages = [
|
||||
{
|
||||
id: 700,
|
||||
jobApplicationId: 42,
|
||||
from: "Company",
|
||||
content: "Acme wants to schedule a call.",
|
||||
subject: "Backend Developer interview",
|
||||
channel: "Email",
|
||||
date: new Date().toISOString(),
|
||||
externalMessageId: body.messageId,
|
||||
externalThreadId: "thread-1",
|
||||
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||
externalTo: "user@example.test",
|
||||
},
|
||||
];
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
messageId: body.messageId,
|
||||
threadId: "thread-1",
|
||||
message: correspondenceMessages[0],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/gmail/import-thread") {
|
||||
return Promise.resolve({ data: { imported: 2, skipped: 0, threadId: "thread-1" } } as any);
|
||||
}
|
||||
if (url === "/correspondence") {
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows ranked Gmail suggestions with reasons and refreshes correspondence after message import", async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
|
||||
expect((await screen.findAllByText(/backend developer interview/i)).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText(/high confidence · score 42/i)).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText(/company: acme/i)).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText(/recruiter email: maria@acme\.test/i)).toBeInTheDocument();
|
||||
|
||||
const importButtons = await screen.findAllByRole("button", { name: /^import email$/i });
|
||||
fireEvent.click(importButtons[importButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/import", { jobApplicationId: 42, messageId: "msg-2" });
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/thread thread-1/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/from maria recruiter <maria@acme\.test>/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/to user@example\.test/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
|
||||
const search = await screen.findByLabelText(/search gmail/i);
|
||||
fireEvent.change(search, { target: { value: 'subject:"Acme" newer_than:30d' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^search$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
jobApplicationId: 42,
|
||||
queryOverride: 'subject:"Acme" newer_than:30d',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,8 +125,66 @@ export interface CorrespondenceMessage {
|
||||
subject?: string;
|
||||
channel?: string;
|
||||
date: string;
|
||||
externalMessageId?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
externalFrom?: string | null;
|
||||
externalTo?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailJobMatchReason {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GmailJobMatchedMessage {
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date?: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
confidence: string;
|
||||
alreadyImported: boolean;
|
||||
matchReasons: GmailJobMatchReason[];
|
||||
}
|
||||
|
||||
export interface GmailJobMatchedThread {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
score: number;
|
||||
confidence: string;
|
||||
hasImportedMessages: boolean;
|
||||
messageCount: number;
|
||||
latestDate?: string;
|
||||
matchReasons: GmailJobMatchReason[];
|
||||
messages: GmailJobMatchedMessage[];
|
||||
}
|
||||
|
||||
export interface GmailJobMatchesResponse {
|
||||
jobApplicationId: number;
|
||||
jobTitle: string;
|
||||
companyName: string;
|
||||
recruiterName?: string | null;
|
||||
recruiterEmail?: string | null;
|
||||
queries: string[];
|
||||
threads: GmailJobMatchedThread[];
|
||||
}
|
||||
|
||||
export interface GmailImportMessageResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
messageId: string;
|
||||
threadId?: string | null;
|
||||
message?: CorrespondenceMessage | null;
|
||||
}
|
||||
|
||||
export interface GmailImportThreadResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
threadId?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailStatus {
|
||||
connected: boolean;
|
||||
|
||||
Reference in New Issue
Block a user