Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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 * * *"
}
]
```
Expand Down
164 changes: 164 additions & 0 deletions apps/web/app/api/reply-tracker/disable-unused-auto-draft/route.ts
Original file line number Diff line number Diff line change
@@ -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: {
Comment on lines +65 to +85
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

N + 1 queries – fetch drafts in batch instead of per‑user loop

The loop issues one executedAction.findMany per user. If thousands of users have auto‑draft enabled the route will:

  • hammer the DB with many small queries,
  • hold the Lambda/function warm longer and increase cold‑start cost,
  • risk timing out for the 10 s default Vercel edge timeout (if not using background).

You can reduce round‑trips with a single query that groups by userId and aggregates the last 10 drafts per user, e.g.:

const draftsByUser = await prisma.executedAction.groupBy({
  by: ["executedRule.userId"],
  where: { /* same filters */ },
  _count: { id: true },
  _max: { createdAt: true },
  take: MAX_DRAFTS_TO_CHECK,
});

Then process the in‑memory groups.
This will scale linearly with returned rows instead of users.

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);
});
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.