import { randomUUID } from "node:crypto"; import type { PostApplicationMessage, PostApplicationMessageType, PostApplicationProcessingStatus, PostApplicationProvider, PostApplicationRelevanceDecision, PostApplicationRouterStageTarget, } from "@shared/types"; import { and, desc, eq } from "drizzle-orm"; import { db, schema } from "../db"; import { normalizeStageTarget, stageTargetFromMessageType, } from "../services/post-application/stage-target"; const { postApplicationMessages } = schema; type UpsertPostApplicationMessageInput = { provider: PostApplicationProvider; accountKey: string; integrationId: string | null; syncRunId: string | null; externalMessageId: string; externalThreadId?: string | null; fromAddress: string; fromDomain?: string | null; senderName?: string | null; subject: string; receivedAt: number; snippet: string; classificationLabel?: string | null; classificationConfidence?: number | null; classificationPayload?: Record | null; relevanceLlmScore?: number | null; relevanceDecision: PostApplicationRelevanceDecision; matchConfidence?: number | null; stageTarget?: PostApplicationRouterStageTarget | null; messageType: PostApplicationMessageType; stageEventPayload?: Record | null; processingStatus: PostApplicationProcessingStatus; matchedJobId?: string | null; decidedAt?: number | null; decidedBy?: string | null; errorCode?: string | null; errorMessage?: string | null; existingMessage?: PostApplicationMessage | null; }; type UpdatePostApplicationMessageSuggestionInput = { id: string; matchedJobId: string | null; matchConfidence?: number | null; processingStatus: PostApplicationProcessingStatus; }; type UpdatePostApplicationMessageDecisionInput = { id: string; processingStatus: Extract< PostApplicationProcessingStatus, "manual_linked" | "ignored" >; matchedJobId: string | null; decidedAt?: number; decidedBy?: string | null; }; export type UpsertPostApplicationMessageResult = { message: PostApplicationMessage; wasCreated: boolean; previousProcessingStatus: PostApplicationProcessingStatus | null; autoLinkTransitioned: boolean; }; function isTerminalProcessingStatus( status: PostApplicationProcessingStatus, ): boolean { return status !== "pending_user"; } function mapRowToPostApplicationMessage( row: typeof postApplicationMessages.$inferSelect, ): PostApplicationMessage { const stageEventPayload = (row.stageEventPayload as Record | null) ?? null; const stageTarget = normalizeStageTarget(stageEventPayload?.suggestedStageTarget) ?? normalizeStageTarget(row.classificationLabel) ?? stageTargetFromMessageType(row.messageType as PostApplicationMessageType); return { id: row.id, provider: row.provider, accountKey: row.accountKey, integrationId: row.integrationId, syncRunId: row.syncRunId, externalMessageId: row.externalMessageId, externalThreadId: row.externalThreadId, fromAddress: row.fromAddress, fromDomain: row.fromDomain, senderName: row.senderName, subject: row.subject, receivedAt: row.receivedAt, snippet: row.snippet, classificationLabel: row.classificationLabel, classificationConfidence: row.classificationConfidence, classificationPayload: (row.classificationPayload as Record | null) ?? null, relevanceLlmScore: row.relevanceLlmScore, relevanceDecision: row.relevanceDecision as PostApplicationRelevanceDecision, matchedJobId: row.matchedJobId, matchConfidence: row.matchConfidence, stageTarget, messageType: row.messageType as PostApplicationMessageType, stageEventPayload, processingStatus: row.processingStatus as PostApplicationProcessingStatus, decidedAt: row.decidedAt, decidedBy: row.decidedBy, errorCode: row.errorCode, errorMessage: row.errorMessage, createdAt: row.createdAt, updatedAt: row.updatedAt, }; } export async function getPostApplicationMessageByExternalId( provider: PostApplicationProvider, accountKey: string, externalMessageId: string, ): Promise { const [row] = await db .select() .from(postApplicationMessages) .where( and( eq(postApplicationMessages.provider, provider), eq(postApplicationMessages.accountKey, accountKey), eq(postApplicationMessages.externalMessageId, externalMessageId), ), ); return row ? mapRowToPostApplicationMessage(row) : null; } export async function getPostApplicationMessageById( id: string, ): Promise { const [row] = await db .select() .from(postApplicationMessages) .where(eq(postApplicationMessages.id, id)); return row ? mapRowToPostApplicationMessage(row) : null; } export async function upsertPostApplicationMessage( input: UpsertPostApplicationMessageInput, ): Promise { const stageTarget = input.stageTarget ?? normalizeStageTarget(input.classificationLabel) ?? stageTargetFromMessageType(input.messageType); const stageEventPayload = { ...(input.stageEventPayload ?? {}), suggestedStageTarget: stageTarget, }; const nowIso = new Date().toISOString(); const existing = input.existingMessage ?? (await getPostApplicationMessageByExternalId( input.provider, input.accountKey, input.externalMessageId, )); if (existing) { const nextProcessingStatus = isTerminalProcessingStatus( existing.processingStatus, ) ? existing.processingStatus : input.processingStatus; const autoLinkTransitioned = existing.processingStatus !== "auto_linked" && nextProcessingStatus === "auto_linked"; await db .update(postApplicationMessages) .set({ integrationId: input.integrationId, syncRunId: input.syncRunId, externalThreadId: input.externalThreadId ?? null, fromAddress: input.fromAddress, fromDomain: input.fromDomain ?? null, senderName: input.senderName ?? null, subject: input.subject, receivedAt: input.receivedAt, snippet: input.snippet, classificationLabel: input.classificationLabel ?? null, classificationConfidence: input.classificationConfidence ?? null, classificationPayload: input.classificationPayload ?? null, relevanceLlmScore: input.relevanceLlmScore ?? null, relevanceDecision: input.relevanceDecision, matchConfidence: input.matchConfidence ?? null, messageType: input.messageType, stageEventPayload, processingStatus: nextProcessingStatus, matchedJobId: input.matchedJobId ?? null, decidedAt: input.decidedAt ?? null, decidedBy: input.decidedBy ?? null, errorCode: input.errorCode ?? null, errorMessage: input.errorMessage ?? null, updatedAt: nowIso, }) .where(eq(postApplicationMessages.id, existing.id)); const updated = await getPostApplicationMessageByExternalId( input.provider, input.accountKey, input.externalMessageId, ); if (!updated) { throw new Error( `Failed to load updated post-application message ${input.externalMessageId}.`, ); } return { message: updated, wasCreated: false, previousProcessingStatus: existing.processingStatus, autoLinkTransitioned, }; } const id = randomUUID(); await db.insert(postApplicationMessages).values({ id, provider: input.provider, accountKey: input.accountKey, integrationId: input.integrationId, syncRunId: input.syncRunId, externalMessageId: input.externalMessageId, externalThreadId: input.externalThreadId ?? null, fromAddress: input.fromAddress, fromDomain: input.fromDomain ?? null, senderName: input.senderName ?? null, subject: input.subject, receivedAt: input.receivedAt, snippet: input.snippet, classificationLabel: input.classificationLabel ?? null, classificationConfidence: input.classificationConfidence ?? null, classificationPayload: input.classificationPayload ?? null, relevanceLlmScore: input.relevanceLlmScore ?? null, relevanceDecision: input.relevanceDecision, matchConfidence: input.matchConfidence ?? null, messageType: input.messageType, stageEventPayload, processingStatus: input.processingStatus, matchedJobId: input.matchedJobId ?? null, decidedAt: input.decidedAt ?? null, decidedBy: input.decidedBy ?? null, errorCode: input.errorCode ?? null, errorMessage: input.errorMessage ?? null, createdAt: nowIso, updatedAt: nowIso, }); const created = await getPostApplicationMessageByExternalId( input.provider, input.accountKey, input.externalMessageId, ); if (!created) { throw new Error( `Failed to load created post-application message ${input.externalMessageId}.`, ); } return { message: created, wasCreated: true, previousProcessingStatus: null, autoLinkTransitioned: input.processingStatus === "auto_linked", }; } export async function updatePostApplicationMessageSuggestion( input: UpdatePostApplicationMessageSuggestionInput, ): Promise { const nowIso = new Date().toISOString(); await db .update(postApplicationMessages) .set({ matchedJobId: input.matchedJobId, ...(input.matchConfidence !== undefined ? { matchConfidence: input.matchConfidence } : {}), processingStatus: input.processingStatus, updatedAt: nowIso, }) .where(eq(postApplicationMessages.id, input.id)); const [row] = await db .select() .from(postApplicationMessages) .where(eq(postApplicationMessages.id, input.id)); return row ? mapRowToPostApplicationMessage(row) : null; } export async function listPostApplicationMessagesByProcessingStatus( provider: PostApplicationProvider, accountKey: string, processingStatus: PostApplicationProcessingStatus, limit = 50, ): Promise { const rows = await db .select() .from(postApplicationMessages) .where( and( eq(postApplicationMessages.provider, provider), eq(postApplicationMessages.accountKey, accountKey), eq(postApplicationMessages.processingStatus, processingStatus), ), ) .orderBy(desc(postApplicationMessages.receivedAt)) .limit(limit); return rows.map(mapRowToPostApplicationMessage); } export async function listPostApplicationMessagesBySyncRun( provider: PostApplicationProvider, accountKey: string, syncRunId: string, limit = 300, ): Promise { const rows = await db .select() .from(postApplicationMessages) .where( and( eq(postApplicationMessages.provider, provider), eq(postApplicationMessages.accountKey, accountKey), eq(postApplicationMessages.syncRunId, syncRunId), ), ) .orderBy(desc(postApplicationMessages.receivedAt)) .limit(limit); return rows.map(mapRowToPostApplicationMessage); } export async function updatePostApplicationMessageDecision( input: UpdatePostApplicationMessageDecisionInput, ): Promise { const decidedAt = input.decidedAt ?? Date.now(); const nowIso = new Date(decidedAt).toISOString(); await db .update(postApplicationMessages) .set({ processingStatus: input.processingStatus, matchedJobId: input.matchedJobId, decidedAt, decidedBy: input.decidedBy ?? null, updatedAt: nowIso, }) .where(eq(postApplicationMessages.id, input.id)); const [row] = await db .select() .from(postApplicationMessages) .where(eq(postApplicationMessages.id, input.id)); return row ? mapRowToPostApplicationMessage(row) : null; }