Avoid reprocessing previously ingested Gmail messages (#213)

* Avoid reprocessing previously ingested Gmail messages

* Avoid duplicate message lookup in Gmail sync upsert path
This commit is contained in:
Shaheer Sarfaraz 2026-02-20 17:20:06 +00:00 committed by GitHub
parent f3c164d252
commit 1e0767a4ed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 119 additions and 18 deletions

View File

@ -44,6 +44,7 @@ type UpsertPostApplicationMessageInput = {
decidedBy?: string | null; decidedBy?: string | null;
errorCode?: string | null; errorCode?: string | null;
errorMessage?: string | null; errorMessage?: string | null;
existingMessage?: PostApplicationMessage | null;
}; };
type UpdatePostApplicationMessageSuggestionInput = { type UpdatePostApplicationMessageSuggestionInput = {
@ -123,7 +124,7 @@ function mapRowToPostApplicationMessage(
}; };
} }
async function getPostApplicationMessageByExternalId( export async function getPostApplicationMessageByExternalId(
provider: PostApplicationProvider, provider: PostApplicationProvider,
accountKey: string, accountKey: string,
externalMessageId: string, externalMessageId: string,
@ -163,11 +164,13 @@ export async function upsertPostApplicationMessage(
suggestedStageTarget: stageTarget, suggestedStageTarget: stageTarget,
}; };
const nowIso = new Date().toISOString(); const nowIso = new Date().toISOString();
const existing = await getPostApplicationMessageByExternalId( const existing =
input.provider, input.existingMessage ??
input.accountKey, (await getPostApplicationMessageByExternalId(
input.externalMessageId, input.provider,
); input.accountKey,
input.externalMessageId,
));
if (existing) { if (existing) {
const nextProcessingStatus = isTerminalProcessingStatus( const nextProcessingStatus = isTerminalProcessingStatus(

View File

@ -40,8 +40,10 @@ vi.mock("@server/repositories/jobs", () => ({
]), ]),
})); }));
const getPostApplicationMessageByExternalId = vi.fn();
const upsertPostApplicationMessage = vi.fn(); const upsertPostApplicationMessage = vi.fn();
vi.mock("@server/repositories/post-application-messages", () => ({ vi.mock("@server/repositories/post-application-messages", () => ({
getPostApplicationMessageByExternalId,
upsertPostApplicationMessage, upsertPostApplicationMessage,
})); }));
@ -54,20 +56,22 @@ vi.mock("@server/repositories/settings", () => ({
getSetting: vi.fn().mockResolvedValue(null), getSetting: vi.fn().mockResolvedValue(null),
})); }));
const llmCallJson = vi.fn().mockResolvedValue({
success: true,
data: {
bestMatchIndex: 1,
confidence: 99,
stageTarget: "assessment",
isRelevant: true,
stageEventPayload: null,
reason: "matches",
},
});
vi.mock("@server/services/llm-service", () => ({ vi.mock("@server/services/llm-service", () => ({
LlmService: class { LlmService: class {
callJson() { callJson() {
return Promise.resolve({ return llmCallJson();
success: true,
data: {
bestMatchIndex: 1,
confidence: 99,
stageTarget: "assessment",
isRelevant: true,
stageEventPayload: null,
reason: "matches",
},
});
} }
}, },
})); }));
@ -83,6 +87,7 @@ function makeJsonResponse(body: unknown): Response {
describe("gmail sync auto-log idempotency", () => { describe("gmail sync auto-log idempotency", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
llmCallJson.mockClear();
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
@ -129,6 +134,41 @@ describe("gmail sync auto-log idempotency", () => {
it("creates auto stage event only on first auto_linked transition", async () => { it("creates auto stage event only on first auto_linked transition", async () => {
const { runGmailIngestionSync } = await import("./gmail-sync"); const { runGmailIngestionSync } = await import("./gmail-sync");
getPostApplicationMessageByExternalId
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "post-msg-1",
provider: "gmail",
accountKey: "default",
integrationId: "integration-1",
syncRunId: "sync-run-1",
externalMessageId: "message-1",
externalThreadId: "thread-1",
fromAddress: "jobs@example.com",
fromDomain: "example.com",
senderName: "Recruiter",
subject: "Interview update",
receivedAt: Date.now(),
snippet: "snippet",
classificationLabel: "assessment",
classificationConfidence: 0.99,
classificationPayload: { method: "smart_router", reason: "matches" },
relevanceLlmScore: 99,
relevanceDecision: "relevant",
matchedJobId: "job-1",
matchConfidence: 99,
stageTarget: "assessment",
messageType: "interview",
stageEventPayload: null,
processingStatus: "auto_linked",
decidedAt: null,
decidedBy: null,
errorCode: null,
errorMessage: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
upsertPostApplicationMessage upsertPostApplicationMessage
.mockResolvedValueOnce({ .mockResolvedValueOnce({
message: { message: {
@ -160,5 +200,6 @@ describe("gmail sync auto-log idempotency", () => {
expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2); expect(upsertPostApplicationMessage).toHaveBeenCalledTimes(2);
expect(transitionStage).toHaveBeenCalledTimes(1); expect(transitionStage).toHaveBeenCalledTimes(1);
expect(llmCallJson).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@ -6,7 +6,10 @@ import {
updatePostApplicationIntegrationSyncState, updatePostApplicationIntegrationSyncState,
upsertConnectedPostApplicationIntegration, upsertConnectedPostApplicationIntegration,
} from "@server/repositories/post-application-integrations"; } from "@server/repositories/post-application-integrations";
import { upsertPostApplicationMessage } from "@server/repositories/post-application-messages"; import {
getPostApplicationMessageByExternalId,
upsertPostApplicationMessage,
} from "@server/repositories/post-application-messages";
import { import {
completePostApplicationSyncRun, completePostApplicationSyncRun,
startPostApplicationSyncRun, startPostApplicationSyncRun,
@ -857,6 +860,60 @@ export async function runGmailIngestionSync(args: {
const date = headerValue(metadata.headers, "Date"); const date = headerValue(metadata.headers, "Date");
const { fromAddress, fromDomain, senderName } = parseFromHeader(from); const { fromAddress, fromDomain, senderName } = parseFromHeader(from);
const receivedAt = parseReceivedAt(date); const receivedAt = parseReceivedAt(date);
const existingMessage = await getPostApplicationMessageByExternalId(
"gmail",
args.accountKey,
metadata.id,
);
if (existingMessage) {
const { message: savedMessage, autoLinkTransitioned } =
await upsertPostApplicationMessage({
provider: "gmail",
accountKey: args.accountKey,
integrationId: integration.id,
syncRunId: syncRun.id,
externalMessageId: metadata.id,
externalThreadId: metadata.threadId,
fromAddress,
fromDomain,
senderName,
subject,
receivedAt,
snippet: metadata.snippet,
classificationLabel: existingMessage.classificationLabel,
classificationConfidence:
existingMessage.classificationConfidence,
classificationPayload: existingMessage.classificationPayload,
relevanceLlmScore: existingMessage.relevanceLlmScore,
relevanceDecision: existingMessage.relevanceDecision,
matchedJobId: existingMessage.matchedJobId,
matchConfidence: existingMessage.matchConfidence,
stageTarget: existingMessage.stageTarget,
messageType: existingMessage.messageType,
stageEventPayload: existingMessage.stageEventPayload,
processingStatus: existingMessage.processingStatus,
existingMessage,
});
if (savedMessage.processingStatus !== "ignored") {
relevant += 1;
}
classified += 1;
if (savedMessage.matchedJobId) {
matched += 1;
}
if (autoLinkTransitioned && savedMessage.matchedJobId) {
await createAutoStageEvent({
jobId: savedMessage.matchedJobId,
stageTarget: savedMessage.stageTarget ?? "no_change",
receivedAt: savedMessage.receivedAt,
note: "Auto-created from Smart Router.",
});
}
return;
}
const fullMessage = await getMessageFull(accessToken, message.id); const fullMessage = await getMessageFull(accessToken, message.id);
const body = extractBodyText(fullMessage.payload); const body = extractBodyText(fullMessage.payload);