chore(M001/S01): auto-commit after complete-slice
This commit is contained in:
@@ -36,6 +36,7 @@ import {
|
||||
GmailImportThreadResult,
|
||||
GmailJobMatchesResponse,
|
||||
GmailStatus,
|
||||
GmailThreadRefreshResult,
|
||||
JobApplication,
|
||||
} from "../types";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
@@ -115,8 +116,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
const [gmailQuery, setGmailQuery] = useState("");
|
||||
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
|
||||
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
|
||||
const [linkedThreadRefresh, setLinkedThreadRefresh] = useState<GmailThreadRefreshResult | null>(null);
|
||||
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||
const autoRefreshKeyRef = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await api.get<CorrespondenceMessage[]>(`/correspondence/${jobId}`);
|
||||
@@ -153,6 +157,47 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
}
|
||||
}, [jobId, toast]);
|
||||
|
||||
const linkedThreadIds = useMemo(
|
||||
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
|
||||
[messages],
|
||||
);
|
||||
|
||||
const refreshLinkedThreads = useCallback(async (mode: "auto" | "manual" = "manual") => {
|
||||
if (!gmailStatus?.connected || linkedThreadIds.length === 0) return null;
|
||||
|
||||
try {
|
||||
setLinkedThreadRefreshLoading(true);
|
||||
const res = await api.post<GmailThreadRefreshResult>("/gmail/refresh-linked-threads", { jobApplicationId: jobId });
|
||||
setLinkedThreadRefresh(res.data);
|
||||
await load();
|
||||
await loadGmailStatus();
|
||||
if (importOpen && importTab === 1) {
|
||||
await loadGmailMatches(gmailQuery);
|
||||
}
|
||||
|
||||
if (mode === "manual") {
|
||||
if (res.data.imported > 0) {
|
||||
toast(`Imported ${res.data.imported} new Gmail message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
||||
} else if (res.data.hasLinkedThreads) {
|
||||
toast("Linked Gmail threads are already current.", "success");
|
||||
} else {
|
||||
toast("This job does not have any linked Gmail threads yet.", "success");
|
||||
}
|
||||
} else if (res.data.imported > 0) {
|
||||
toast(`Linked Gmail threads imported ${res.data.imported} new message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
||||
}
|
||||
|
||||
return res.data;
|
||||
} catch (error: any) {
|
||||
if (mode === "manual") {
|
||||
toast(getApiErrorMessage(error, "Failed to refresh linked Gmail threads."), "error");
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
setLinkedThreadRefreshLoading(false);
|
||||
}
|
||||
}, [gmailQuery, gmailStatus?.connected, importOpen, importTab, jobId, linkedThreadIds.length, load, loadGmailMatches, loadGmailStatus, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
@@ -164,9 +209,20 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
}, [messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen) return;
|
||||
void loadGmailStatus();
|
||||
}, [importOpen, loadGmailStatus]);
|
||||
}, [loadGmailStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
||||
autoRefreshKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshKey = `${jobId}:${linkedThreadIds.join("|")}`;
|
||||
if (autoRefreshKeyRef.current === refreshKey) return;
|
||||
autoRefreshKeyRef.current = refreshKey;
|
||||
void refreshLinkedThreads("auto");
|
||||
}, [gmailStatus?.connected, jobId, linkedThreadIds, refreshLinkedThreads]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
||||
@@ -261,6 +317,7 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
await api.delete("/gmail/connection");
|
||||
setGmailStatus({ connected: false });
|
||||
setGmailMatches(null);
|
||||
setLinkedThreadRefresh(null);
|
||||
toast(t("googleUnlinked"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
||||
@@ -391,6 +448,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{gmailStatus?.connected ? (
|
||||
<>
|
||||
{linkedThreadIds.length > 0 ? (
|
||||
<Button variant="outlined" onClick={() => void refreshLinkedThreads("manual")} disabled={linkedThreadRefreshLoading}>
|
||||
{linkedThreadRefreshLoading ? "Refreshing linked threads..." : "Refresh linked threads"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
|
||||
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
|
||||
</>
|
||||
@@ -419,6 +481,22 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
</Box>
|
||||
) : null}
|
||||
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||
{linkedThreadIds.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
|
||||
{linkedThreadRefresh ? (
|
||||
<Chip
|
||||
size="small"
|
||||
variant="outlined"
|
||||
label={linkedThreadRefresh.imported > 0
|
||||
? `Last refresh imported ${linkedThreadRefresh.imported} new message${linkedThreadRefresh.imported === 1 ? "" : "s"}`
|
||||
: linkedThreadRefresh.hasLinkedThreads
|
||||
? `Last refresh checked ${linkedThreadRefresh.threadsChecked} linked thread${linkedThreadRefresh.threadsChecked === 1 ? "" : "s"}`
|
||||
: "No linked Gmail threads yet"}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
||||
{gmailMatchesLoading ? (
|
||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||
|
||||
@@ -142,6 +142,61 @@ describe("correspondence Gmail import", () => {
|
||||
});
|
||||
|
||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||
if (url === "/gmail/refresh-linked-threads") {
|
||||
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
|
||||
if (!hasReply && correspondenceMessages.some((message) => message.externalThreadId === "thread-1")) {
|
||||
correspondenceMessages = [
|
||||
...correspondenceMessages,
|
||||
{
|
||||
id: 701,
|
||||
jobApplicationId: 42,
|
||||
from: "Me",
|
||||
content: "Following up on the role.",
|
||||
subject: "Backend Developer follow-up",
|
||||
channel: "Email",
|
||||
date: new Date().toISOString(),
|
||||
externalMessageId: "msg-2",
|
||||
externalThreadId: "thread-1",
|
||||
externalFrom: "user@example.test",
|
||||
externalTo: "Maria Recruiter <maria@acme.test>",
|
||||
},
|
||||
];
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
jobApplicationId: body.jobApplicationId,
|
||||
threadsChecked: 1,
|
||||
imported: 1,
|
||||
skipped: 1,
|
||||
hasLinkedThreads: true,
|
||||
refreshedAt: new Date().toISOString(),
|
||||
threads: [
|
||||
{
|
||||
threadId: "thread-1",
|
||||
imported: 1,
|
||||
skipped: 1,
|
||||
totalMessages: 2,
|
||||
status: "imported-new-messages",
|
||||
latestMessageDate: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
jobApplicationId: body.jobApplicationId,
|
||||
threadsChecked: correspondenceMessages.some((message) => message.externalThreadId === "thread-1") ? 1 : 0,
|
||||
imported: 0,
|
||||
skipped: correspondenceMessages.some((message) => message.externalThreadId === "thread-1") ? correspondenceMessages.filter((message) => message.externalThreadId === "thread-1").length : 0,
|
||||
hasLinkedThreads: correspondenceMessages.some((message) => message.externalThreadId === "thread-1"),
|
||||
refreshedAt: new Date().toISOString(),
|
||||
threads: correspondenceMessages.some((message) => message.externalThreadId === "thread-1")
|
||||
? [{ threadId: "thread-1", imported: 0, skipped: correspondenceMessages.filter((message) => message.externalThreadId === "thread-1").length, totalMessages: correspondenceMessages.filter((message) => message.externalThreadId === "thread-1").length, status: "already-current", latestMessageDate: new Date().toISOString() }]
|
||||
: [],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/gmail/import") {
|
||||
correspondenceMessages = [
|
||||
{
|
||||
@@ -208,6 +263,34 @@ describe("correspondence Gmail import", () => {
|
||||
expect(await screen.findByText(/to user@example\.test/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("automatically refreshes already-linked Gmail threads without manual re-import", async () => {
|
||||
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: "msg-1",
|
||||
externalThreadId: "thread-1",
|
||||
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||
externalTo: "user@example.test",
|
||||
},
|
||||
];
|
||||
|
||||
renderDialog();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/refresh-linked-threads", { jobApplicationId: 42 });
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/backend developer follow-up/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/following up on the role\./i)).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
|
||||
renderDialog();
|
||||
|
||||
|
||||
@@ -198,6 +198,25 @@ export interface GmailImportThreadResult {
|
||||
threadId?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailThreadRefreshThreadResult {
|
||||
threadId: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
totalMessages: number;
|
||||
status: string;
|
||||
latestMessageDate?: string;
|
||||
}
|
||||
|
||||
export interface GmailThreadRefreshResult {
|
||||
jobApplicationId: number;
|
||||
threadsChecked: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
hasLinkedThreads: boolean;
|
||||
refreshedAt: string;
|
||||
threads: GmailThreadRefreshThreadResult[];
|
||||
}
|
||||
|
||||
export interface GmailStatus {
|
||||
connected: boolean;
|
||||
gmailAddress?: string;
|
||||
|
||||
Reference in New Issue
Block a user