diff --git a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/disable-unused-auto-drafts.test.ts b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/disable-unused-auto-drafts.test.ts new file mode 100644 index 0000000000..e40a913186 --- /dev/null +++ b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/disable-unused-auto-drafts.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { disableUnusedAutoDrafts } from "./disable-unused-auto-drafts"; +import prisma from "@/utils/prisma"; +import { ActionType, SystemType } from "@prisma/client"; +import subDays from "date-fns/subDays"; + +vi.mock("@/utils/prisma", () => ({ + default: { + emailAccount: { + findMany: vi.fn(), + }, + executedAction: { + findMany: vi.fn(), + }, + action: { + deleteMany: vi.fn(), + }, + }, +})); + +vi.mock("@/utils/error", () => ({ + captureException: vi.fn(), +})); + +describe("disableUnusedAutoDrafts", () => { + const mockFindManyEmailAccount = prisma.emailAccount.findMany as Mock; + const mockFindManyExecutedAction = prisma.executedAction.findMany as Mock; + const mockDeleteManyAction = prisma.action.deleteMany as Mock; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should disable auto-draft for users who haven't used any of their last 10 drafts", async () => { + const twoDaysAgo = subDays(new Date(), 2); + + // Mock user with auto-draft enabled + mockFindManyEmailAccount.mockResolvedValue([ + { + id: "account-123", + rules: [{ id: "rule-123" }], + }, + ]); + + // Mock 10 unused drafts (all older than 1 day) + const mockUnusedDrafts = Array.from({ length: 10 }, (_, i) => ({ + id: `action-${i}`, + wasDraftSent: false, + draftSendLog: null, + createdAt: twoDaysAgo, + })); + + mockFindManyExecutedAction.mockResolvedValue(mockUnusedDrafts); + + const result = await disableUnusedAutoDrafts(); + + // Verify auto-draft was disabled + expect(mockDeleteManyAction).toHaveBeenCalledWith({ + where: { + rule: { + emailAccountId: "account-123", + systemType: SystemType.TO_REPLY, + }, + type: ActionType.DRAFT_EMAIL, + content: null, + }, + }); + + expect(result).toEqual({ + usersChecked: 1, + usersDisabled: 1, + errors: 0, + }); + }); + + it("should not disable auto-draft if user has sent at least one draft", async () => { + const twoDaysAgo = subDays(new Date(), 2); + + mockFindManyEmailAccount.mockResolvedValue([ + { + id: "account-456", + rules: [{ id: "rule-456" }], + }, + ]); + + // Mock 10 drafts where one was sent + const mockDraftsWithOneSent = Array.from({ length: 10 }, (_, i) => ({ + id: `action-${i}`, + wasDraftSent: i === 5, // One draft was sent + draftSendLog: null, + createdAt: twoDaysAgo, + })); + + mockFindManyExecutedAction.mockResolvedValue(mockDraftsWithOneSent); + + const result = await disableUnusedAutoDrafts(); + + // Verify auto-draft was NOT disabled + expect(mockDeleteManyAction).not.toHaveBeenCalled(); + + expect(result).toEqual({ + usersChecked: 1, + usersDisabled: 0, + errors: 0, + }); + }); + + it("should not disable auto-draft if user has draft with send log", async () => { + const twoDaysAgo = subDays(new Date(), 2); + + mockFindManyEmailAccount.mockResolvedValue([ + { + id: "account-789", + rules: [{ id: "rule-789" }], + }, + ]); + + // Mock 10 drafts where one has a send log + const mockDraftsWithSendLog = Array.from({ length: 10 }, (_, i) => ({ + id: `action-${i}`, + wasDraftSent: false, + draftSendLog: i === 3 ? { id: "log-123" } : null, // One has send log + createdAt: twoDaysAgo, + })); + + mockFindManyExecutedAction.mockResolvedValue(mockDraftsWithSendLog); + + const result = await disableUnusedAutoDrafts(); + + // Verify auto-draft was NOT disabled + expect(mockDeleteManyAction).not.toHaveBeenCalled(); + + expect(result).toEqual({ + usersChecked: 1, + usersDisabled: 0, + errors: 0, + }); + }); + + it("should skip users with fewer than 10 drafts", async () => { + const twoDaysAgo = subDays(new Date(), 2); + + mockFindManyEmailAccount.mockResolvedValue([ + { + id: "account-999", + rules: [{ id: "rule-999" }], + }, + ]); + + // Mock only 5 drafts (less than 10) + const mockFewDrafts = Array.from({ length: 5 }, (_, i) => ({ + id: `action-${i}`, + wasDraftSent: false, + draftSendLog: null, + createdAt: twoDaysAgo, + })); + + mockFindManyExecutedAction.mockResolvedValue(mockFewDrafts); + + const result = await disableUnusedAutoDrafts(); + + // Verify auto-draft was NOT disabled + expect(mockDeleteManyAction).not.toHaveBeenCalled(); + + expect(result).toEqual({ + usersChecked: 1, + usersDisabled: 0, + errors: 0, + }); + }); + + it("should handle multiple users correctly", async () => { + const twoDaysAgo = subDays(new Date(), 2); + + mockFindManyEmailAccount.mockResolvedValue([ + { id: "account-1", rules: [{ id: "rule-1" }] }, + { id: "account-2", rules: [{ id: "rule-2" }] }, + { id: "account-3", rules: [{ id: "rule-3" }] }, + ]); + + // Mock different scenarios for each user + mockFindManyExecutedAction + .mockResolvedValueOnce( + // User 1: 10 unused drafts - should be disabled + Array.from({ length: 10 }, (_, i) => ({ + id: `action-1-${i}`, + wasDraftSent: false, + draftSendLog: null, + createdAt: twoDaysAgo, + })), + ) + .mockResolvedValueOnce( + // User 2: 10 drafts with one sent - should NOT be disabled + Array.from({ length: 10 }, (_, i) => ({ + id: `action-2-${i}`, + wasDraftSent: i === 0, + draftSendLog: null, + createdAt: twoDaysAgo, + })), + ) + .mockResolvedValueOnce( + // User 3: Only 5 drafts - should NOT be disabled + Array.from({ length: 5 }, (_, i) => ({ + id: `action-3-${i}`, + wasDraftSent: false, + draftSendLog: null, + createdAt: twoDaysAgo, + })), + ); + + const result = await disableUnusedAutoDrafts(); + + // Only user 1 should have auto-draft disabled + expect(mockDeleteManyAction).toHaveBeenCalledTimes(1); + expect(mockDeleteManyAction).toHaveBeenCalledWith({ + where: { + rule: { + emailAccountId: "account-1", + systemType: SystemType.TO_REPLY, + }, + type: ActionType.DRAFT_EMAIL, + content: null, + }, + }); + + expect(result).toEqual({ + usersChecked: 3, + usersDisabled: 1, + errors: 0, + }); + }); + + it("should handle errors gracefully", async () => { + mockFindManyEmailAccount.mockResolvedValue([ + { id: "account-error", rules: [{ id: "rule-error" }] }, + ]); + + // Mock an error when finding drafts + mockFindManyExecutedAction.mockRejectedValue(new Error("Database error")); + + const result = await disableUnusedAutoDrafts(); + + expect(result).toEqual({ + usersChecked: 1, + usersDisabled: 0, + errors: 1, + }); + }); +}); diff --git a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/disable-unused-auto-drafts.ts b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/disable-unused-auto-drafts.ts new file mode 100644 index 0000000000..57198da4cc --- /dev/null +++ b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/disable-unused-auto-drafts.ts @@ -0,0 +1,134 @@ +import subDays from "date-fns/subDays"; +import prisma from "@/utils/prisma"; +import { ActionType, SystemType } from "@prisma/client"; +import { captureException } from "@/utils/error"; +import { createScopedLogger } from "@/utils/logger"; + +const MAX_DRAFTS_TO_CHECK = 10; + +const logger = createScopedLogger("auto-draft/disable-unused"); + +/** + * Disables auto-draft feature for users who haven't used their last 10 drafts + * Only checks drafts that are more than a day old to give users time to use them + */ +export async function disableUnusedAutoDrafts() { + logger.info("Starting to check for unused auto-drafts"); + + const oneDayAgo = subDays(new Date(), 1); + + // TODO: may need to make this more efficient + // Find all users who have the auto-draft feature enabled (have an Action of type DRAFT_EMAIL) + const emailAccountsWithAutoDraft = await prisma.emailAccount.findMany({ + where: { + rules: { + some: { + systemType: SystemType.TO_REPLY, + actions: { + some: { + type: ActionType.DRAFT_EMAIL, + }, + }, + }, + }, + }, + select: { + id: true, + rules: { + where: { + systemType: SystemType.TO_REPLY, + }, + select: { + id: true, + }, + }, + }, + }); + + logger.info( + `Found ${emailAccountsWithAutoDraft.length} users with auto-draft enabled`, + ); + + const results = { + usersChecked: emailAccountsWithAutoDraft.length, + usersDisabled: 0, + errors: 0, + }; + + // Process each user + for (const emailAccount of emailAccountsWithAutoDraft) { + const emailAccountId = emailAccount.id; + + try { + // Find the last 10 drafts created for the user + const lastTenDrafts = await prisma.executedAction.findMany({ + where: { + executedRule: { + emailAccountId, + rule: { + systemType: SystemType.TO_REPLY, + }, + }, + type: ActionType.DRAFT_EMAIL, + draftId: { not: null }, + createdAt: { lt: oneDayAgo }, // Only check drafts older than a day + }, + orderBy: { + createdAt: "desc", + }, + take: MAX_DRAFTS_TO_CHECK, + select: { + id: true, + wasDraftSent: true, + draftSendLog: { + select: { + id: true, + }, + }, + }, + }); + + // Skip if user has fewer than 10 drafts (not enough data to make a decision) + if (lastTenDrafts.length < MAX_DRAFTS_TO_CHECK) { + logger.info("Skipping user - only has few drafts", { + emailAccountId, + numDrafts: lastTenDrafts.length, + }); + continue; + } + + // Check if any of the drafts were sent + const anyDraftsSent = lastTenDrafts.some( + (draft) => draft.wasDraftSent === true || draft.draftSendLog, + ); + + // If none of the drafts were sent, disable auto-draft + if (!anyDraftsSent) { + logger.info("Disabling auto-draft for user - last 10 drafts not used", { + emailAccountId, + }); + + // Delete the DRAFT_EMAIL actions from all TO_REPLY rules + await prisma.action.deleteMany({ + where: { + rule: { + emailAccountId, + systemType: SystemType.TO_REPLY, + }, + type: ActionType.DRAFT_EMAIL, + content: null, + }, + }); + + results.usersDisabled++; + } + } catch (error) { + logger.error("Error processing user", { emailAccountId, error }); + captureException(error); + results.errors++; + } + } + + logger.info("Completed auto-draft usage check", results); + return results; +} diff --git a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts index ac1bd65955..49fd8b483e 100644 --- a/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts +++ b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts @@ -1,158 +1,11 @@ import { NextResponse } from "next/server"; -import subDays from "date-fns/subDays"; import { withError } from "@/utils/middleware"; -import prisma from "@/utils/prisma"; -import { ActionType, SystemType } from "@prisma/client"; -import { createScopedLogger } from "@/utils/logger"; import { hasPostCronSecret } from "@/utils/cron"; import { captureException } from "@/utils/error"; +import { disableUnusedAutoDrafts } from "./disable-unused-auto-drafts"; -const logger = createScopedLogger("auto-draft/disable-unused"); - -// Force dynamic to ensure fresh data on each request -export const dynamic = "force-dynamic"; export const maxDuration = 300; -const MAX_DRAFTS_TO_CHECK = 10; - -/** - * Disables auto-draft feature for users who haven't used their last 10 drafts - * Only checks drafts that are more than a day old to give users time to use them - */ -async function disableUnusedAutoDrafts() { - logger.info("Starting to check for unused auto-drafts"); - - const oneDayAgo = subDays(new Date(), 1); - - // TODO: may need to make this more efficient - // Find all users who have the auto-draft feature enabled (have an Action of type DRAFT_EMAIL) - const emailAccountsWithAutoDraft = await prisma.emailAccount.findMany({ - where: { - rules: { - some: { - systemType: SystemType.TO_REPLY, - actions: { - some: { - type: ActionType.DRAFT_EMAIL, - }, - }, - }, - }, - }, - select: { - id: true, - rules: { - where: { - systemType: SystemType.TO_REPLY, - }, - select: { - id: true, - }, - }, - }, - }); - - logger.info( - `Found ${emailAccountsWithAutoDraft.length} users with auto-draft enabled`, - ); - - const results = { - usersChecked: emailAccountsWithAutoDraft.length, - usersDisabled: 0, - errors: 0, - }; - - // Process each user - for (const emailAccount of emailAccountsWithAutoDraft) { - const emailAccountId = emailAccount.id; - - try { - // Find the last 10 drafts created for the user - const lastTenDrafts = await prisma.executedAction.findMany({ - where: { - executedRule: { - emailAccountId, - rule: { - systemType: SystemType.TO_REPLY, - }, - }, - type: ActionType.DRAFT_EMAIL, - draftId: { not: null }, - createdAt: { lt: oneDayAgo }, // Only check drafts older than a day - }, - orderBy: { - createdAt: "desc", - }, - take: MAX_DRAFTS_TO_CHECK, - select: { - id: true, - wasDraftSent: true, - draftSendLog: { - select: { - id: true, - }, - }, - }, - }); - - // Skip if user has fewer than 10 drafts (not enough data to make a decision) - if (lastTenDrafts.length < MAX_DRAFTS_TO_CHECK) { - logger.info("Skipping user - only has few drafts", { - emailAccountId, - numDrafts: lastTenDrafts.length, - }); - continue; - } - - // Check if any of the drafts were sent - const anyDraftsSent = lastTenDrafts.some( - (draft) => draft.wasDraftSent === true || draft.draftSendLog, - ); - - // If none of the drafts were sent, disable auto-draft - if (!anyDraftsSent) { - logger.info("Disabling auto-draft for user - last 10 drafts not used", { - emailAccountId, - }); - - // Delete the DRAFT_EMAIL actions from all TO_REPLY rules - await prisma.action.deleteMany({ - where: { - rule: { - emailAccountId, - systemType: SystemType.TO_REPLY, - }, - type: ActionType.DRAFT_EMAIL, - content: null, - }, - }); - - results.usersDisabled++; - } - } catch (error) { - logger.error("Error processing user", { emailAccountId, error }); - captureException(error); - results.errors++; - } - } - - logger.info("Completed auto-draft usage check", results); - return results; -} - -// For easier local testing -// export const GET = withError(async (request) => { -// if (!hasCronSecret(request)) { -// captureException( -// new Error("Unauthorized request: api/auto-draft/disable-unused"), -// ); -// return new Response("Unauthorized", { status: 401 }); -// } - -// const results = await disableUnusedAutoDrafts(); -// return NextResponse.json(results); -// }); - export const POST = withError(async (request) => { if (!(await hasPostCronSecret(request))) { captureException( diff --git a/version.txt b/version.txt index 97455a7b94..40ba2d579b 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.3.21 \ No newline at end of file +v1.3.22 \ No newline at end of file