diff --git a/README.md b/README.md index 6a93a4260a..472cc08d0f 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ To start watching emails visit: `/api/google/watch/all` ### Watching for email updates Set a cron job to run these: -The Google watch is necessary. The Resend one is optional. +The Google watch is necessary. Others are optional. ```json "crons": [ @@ -222,6 +222,10 @@ The Google watch is necessary. The Resend one is optional. { "path": "/api/resend/summary/all", "schedule": "0 16 * * 1" + }, + { + "path": "/api/reply-tracker/disable-unused-auto-draft", + "schedule": "0 3 * * *" } ] ``` 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 new file mode 100644 index 0000000000..e14aadc1dc --- /dev/null +++ b/apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts @@ -0,0 +1,164 @@ +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"; + +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 usersWithAutoDraft = await prisma.user.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 ${usersWithAutoDraft.length} users with auto-draft enabled`, + ); + + const results = { + usersChecked: usersWithAutoDraft.length, + usersDisabled: 0, + errors: 0, + }; + + // Process each user + for (const user of usersWithAutoDraft) { + try { + // Find the last 10 drafts created for the user + const lastTenDrafts = await prisma.executedAction.findMany({ + where: { + executedRule: { + userId: user.id, + 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", { + userId: user.id, + 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", { + userId: user.id, + }); + + // Delete the DRAFT_EMAIL actions from all TO_REPLY rules + await prisma.action.deleteMany({ + where: { + rule: { + userId: user.id, + systemType: SystemType.TO_REPLY, + }, + type: ActionType.DRAFT_EMAIL, + content: null, + }, + }); + + results.usersDisabled++; + } + } catch (error) { + logger.error("Error processing user", { userId: user.id, 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: Request) => { + if (!(await hasPostCronSecret(request))) { + captureException( + new Error("Unauthorized cron request: api/auto-draft/disable-unused"), + ); + return new Response("Unauthorized", { status: 401 }); + } + + const results = await disableUnusedAutoDrafts(); + return NextResponse.json(results); +}); diff --git a/apps/web/package.json b/apps/web/package.json index 569cbbc043..930e5b836f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -76,6 +76,7 @@ "@upstash/redis": "1.34.7", "@vercel/analytics": "1.5.0", "ai": "4.3.6", + "ai-fallback": "0.1.2", "braintrust": "0.0.197", "capital-case": "2.0.0", "cheerio": "1.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ddd297179..cd8bc7a5a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -293,6 +293,9 @@ importers: ai: specifier: 4.3.6 version: 4.3.6(react@19.1.0)(zod@3.24.2) + ai-fallback: + specifier: 0.1.2 + version: 0.1.2(zod@3.24.2) braintrust: specifier: 0.0.197 version: 0.0.197(@aws-sdk/credential-provider-web-identity@3.750.0)(openai@4.93.0(encoding@0.1.13)(ws@8.18.0)(zod@3.24.2))(react@19.1.0)(sswr@2.2.0(svelte@4.2.12))(svelte@4.2.12)(vue@3.4.19(typescript@5.8.3))(zod@3.24.2) @@ -5980,6 +5983,9 @@ packages: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} + ai-fallback@0.1.2: + resolution: {integrity: sha512-O84i0fC4iMe9FTXu7anHq6K/FQA75JAWthf6iBgQmHug5wTuJilGApqqsWC4685Ul5hsWC2xEnLD82dGuxUvjw==} + ai@3.4.33: resolution: {integrity: sha512-plBlrVZKwPoRTmM8+D1sJac9Bq8eaa2jiZlHLZIWekKWI1yMWYZvCCEezY9ASPwRhULYDJB2VhKOBUUeg3S5JQ==} engines: {node: '>=18'} @@ -18254,6 +18260,13 @@ snapshots: clean-stack: 2.2.0 indent-string: 4.0.0 + ai-fallback@0.1.2(zod@3.24.2): + dependencies: + '@ai-sdk/provider': 1.1.3 + '@ai-sdk/provider-utils': 2.2.7(zod@3.24.2) + transitivePeerDependencies: + - zod + ai@3.4.33(openai@4.93.0(encoding@0.1.13)(ws@8.18.0)(zod@3.24.2))(react@19.1.0)(sswr@2.2.0(svelte@4.2.12))(svelte@4.2.12)(vue@3.4.19(typescript@5.8.3))(zod@3.24.2): dependencies: '@ai-sdk/provider': 0.0.26