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:
parent
f3c164d252
commit
1e0767a4ed
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user