From 924d6e318fccea89b6a4cae0ada81466d09b81ba Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sat, 19 Apr 2025 22:55:10 +0300 Subject: [PATCH 01/21] email account. wip --- .../app/(app)/automation/onboarding/page.tsx | 46 ++-- apps/web/app/(app)/license/page.tsx | 2 +- apps/web/app/(app)/premium/Pricing.tsx | 2 +- .../(app)/settings/MultiAccountSection.tsx | 12 +- apps/web/app/(app)/settings/page.tsx | 2 +- apps/web/app/(app)/setup/page.tsx | 26 ++- apps/web/app/(app)/smart-categories/page.tsx | 12 +- apps/web/app/(app)/usage/usage.tsx | 4 +- .../call-analyze-pattern-api.ts | 6 +- .../api/ai/analyze-sender-pattern/route.ts | 90 ++++---- .../app/api/ai/compose-autocomplete/route.ts | 17 +- apps/web/app/api/ai/models/route.ts | 16 +- apps/web/app/api/ai/summarise/controller.ts | 4 +- apps/web/app/api/ai/summarise/route.ts | 8 +- apps/web/app/api/clean/gmail/route.ts | 36 +-- apps/web/app/api/clean/history/route.ts | 10 +- apps/web/app/api/clean/route.ts | 37 ++-- apps/web/app/api/clean/save-result.ts | 45 ---- apps/web/app/api/google/watch/all/route.ts | 114 +++++----- apps/web/app/api/google/watch/controller.ts | 26 ++- apps/web/app/api/google/watch/route.ts | 15 +- .../google/webhook/process-history-item.ts | 16 +- .../app/api/google/webhook/process-history.ts | 109 ++++----- apps/web/app/api/google/webhook/types.ts | 6 +- .../reply-tracker/process-previous/route.ts | 26 +-- apps/web/app/api/resend/summary/all/route.ts | 22 +- apps/web/app/api/resend/summary/route.ts | 37 +++- .../senders/batch/handle-batch-validation.ts | 2 +- .../categorize/senders/batch/handle-batch.ts | 23 +- .../user/categorize/senders/progress/route.ts | 9 +- apps/web/app/api/user/me/route.ts | 46 ++-- apps/web/app/api/user/rules/prompt/route.ts | 12 +- apps/web/components/PremiumAlert.tsx | 2 +- apps/web/prisma/schema.prisma | 84 ++++--- apps/web/utils/actions/ai-rule.ts | 206 +++++++++--------- apps/web/utils/actions/assess.ts | 32 +-- apps/web/utils/actions/categorize.ts | 21 +- apps/web/utils/actions/clean.ts | 57 +++-- apps/web/utils/actions/cold-email.ts | 25 ++- apps/web/utils/actions/generate-reply.ts | 9 +- apps/web/utils/actions/reply-tracking.ts | 12 +- apps/web/utils/actions/rule.ts | 64 +++--- apps/web/utils/actions/user.ts | 14 +- apps/web/utils/actions/webhook.ts | 8 +- .../ai/assistant/process-user-request.ts | 24 +- apps/web/utils/ai/choose-rule/match-rules.ts | 7 +- apps/web/utils/ai/choose-rule/run-rules.ts | 16 +- .../example-matches/find-example-matches.ts | 4 +- apps/web/utils/ai/group/create-group.ts | 6 +- apps/web/utils/ai/rule/create-rule.ts | 7 +- apps/web/utils/ai/rule/diff-rules.ts | 4 +- apps/web/utils/ai/rule/find-existing-rules.ts | 4 +- .../ai/rule/generate-prompt-on-delete-rule.ts | 5 +- .../ai/rule/generate-prompt-on-update-rule.ts | 5 +- .../utils/ai/rule/generate-rules-prompt.ts | 5 +- apps/web/utils/ai/rule/prompt-to-rules.ts | 4 +- .../utils/categorize/senders/categorize.ts | 4 +- apps/web/utils/cold-email/is-cold-email.ts | 28 ++- apps/web/utils/llms/types.ts | 9 +- .../utils/redis/categorization-progress.ts | 35 ++- apps/web/utils/redis/clean.ts | 45 ++-- apps/web/utils/redis/clean.types.ts | 2 +- apps/web/utils/redis/reply.ts | 18 +- apps/web/utils/reply-tracker/enable.ts | 75 ++++--- .../web/utils/reply-tracker/generate-draft.ts | 11 +- apps/web/utils/reply-tracker/outbound.ts | 15 +- apps/web/utils/rule/prompt-file.ts | 74 ++++--- apps/web/utils/rule/rule.ts | 17 +- apps/web/utils/upstash/categorize-senders.ts | 33 ++- apps/web/utils/user/get.ts | 24 +- apps/web/utils/user/validate.ts | 22 +- 71 files changed, 1002 insertions(+), 873 deletions(-) delete mode 100644 apps/web/app/api/clean/save-result.ts diff --git a/apps/web/app/(app)/automation/onboarding/page.tsx b/apps/web/app/(app)/automation/onboarding/page.tsx index 9278b2bddc..9096733323 100644 --- a/apps/web/app/(app)/automation/onboarding/page.tsx +++ b/apps/web/app/(app)/automation/onboarding/page.tsx @@ -12,9 +12,10 @@ import type { CategoryAction } from "@/utils/actions/rule.validation"; export default async function OnboardingPage() { const session = await auth(); - if (!session?.user.id) return
Not authenticated
; + const email = session?.user.email; + if (!email) return
Not authenticated
; - const defaultValues = await getUserPreferences(session.user.id); + const defaultValues = await getUserPreferences({ email }); return ( @@ -37,16 +38,24 @@ type UserPreferences = Prisma.UserGetPayload<{ }; }>; -async function getUserPreferences(userId: string) { - const user = await prisma.user.findUnique({ - where: { id: userId }, +async function getUserPreferences({ + email, +}: { + email: string; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { - rules: { + user: { select: { - systemType: true, - actions: { + rules: { select: { - type: true, + systemType: true, + actions: { + select: { + type: true, + }, + }, }, }, }, @@ -54,16 +63,19 @@ async function getUserPreferences(userId: string) { coldEmailBlocker: true, }, }); - if (!user) return undefined; + if (!emailAccount) return undefined; return { - toReply: getToReplySetting(user.rules), - coldEmails: getColdEmailSetting(user.coldEmailBlocker), - newsletter: getRuleSetting(SystemType.NEWSLETTER, user.rules), - marketing: getRuleSetting(SystemType.MARKETING, user.rules), - calendar: getRuleSetting(SystemType.CALENDAR, user.rules), - receipt: getRuleSetting(SystemType.RECEIPT, user.rules), - notification: getRuleSetting(SystemType.NOTIFICATION, user.rules), + toReply: getToReplySetting(emailAccount.user.rules), + coldEmails: getColdEmailSetting(emailAccount.coldEmailBlocker), + newsletter: getRuleSetting(SystemType.NEWSLETTER, emailAccount.user.rules), + marketing: getRuleSetting(SystemType.MARKETING, emailAccount.user.rules), + calendar: getRuleSetting(SystemType.CALENDAR, emailAccount.user.rules), + receipt: getRuleSetting(SystemType.RECEIPT, emailAccount.user.rules), + notification: getRuleSetting( + SystemType.NOTIFICATION, + emailAccount.user.rules, + ), }; } diff --git a/apps/web/app/(app)/license/page.tsx b/apps/web/app/(app)/license/page.tsx index c1967b19d7..98730648ae 100644 --- a/apps/web/app/(app)/license/page.tsx +++ b/apps/web/app/(app)/license/page.tsx @@ -25,7 +25,7 @@ export default function LicensePage(props: {
- {data?.premium?.lemonLicenseKey && ( + {data?.user.premium?.lemonLicenseKey && ( @@ -93,12 +93,14 @@ export function MultiAccountSection() {
diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index 4cbc2d6d41..5e8ca4fe52 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -17,7 +17,7 @@ export default async function SettingsPage() { if (!session?.user.email) return ; - const user = await prisma.user.findUnique({ + const user = await prisma.emailAccount.findUnique({ where: { email: session.user.email }, select: { about: true, diff --git a/apps/web/app/(app)/setup/page.tsx b/apps/web/app/(app)/setup/page.tsx index df63c2522e..1f2f574f6a 100644 --- a/apps/web/app/(app)/setup/page.tsx +++ b/apps/web/app/(app)/setup/page.tsx @@ -17,25 +17,29 @@ import { REPLY_ZERO_ONBOARDING_COOKIE } from "@/utils/cookies"; export default async function SetupPage() { const session = await auth(); - const userId = session?.user.id; - if (!userId) throw new Error("Not authenticated"); + const email = session?.user.email; + if (!email) throw new Error("Not authenticated"); - const user = await prisma.user.findUnique({ - where: { id: userId }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { coldEmailBlocker: true, - rules: { select: { id: true }, take: 1 }, - newsletters: { - where: { status: { not: null } }, - take: 1, + user: { + select: { + rules: { select: { id: true }, take: 1 }, + newsletters: { + where: { status: { not: null } }, + take: 1, + }, + }, }, }, }); - if (!user) throw new Error("User not found"); + if (!emailAccount) throw new Error("User not found"); - const isAiAssistantConfigured = user.rules.length > 0; - const isBulkUnsubscribeConfigured = user.newsletters.length > 0; + const isAiAssistantConfigured = emailAccount.user.rules.length > 0; + const isBulkUnsubscribeConfigured = emailAccount.user.newsletters.length > 0; const cookieStore = await cookies(); const isReplyTrackerConfigured = cookieStore.get(REPLY_ZERO_ONBOARDING_COOKIE)?.value === "true"; diff --git a/apps/web/app/(app)/smart-categories/page.tsx b/apps/web/app/(app)/smart-categories/page.tsx index 8fa15869f6..9b5035ee75 100644 --- a/apps/web/app/(app)/smart-categories/page.tsx +++ b/apps/web/app/(app)/smart-categories/page.tsx @@ -35,7 +35,7 @@ export default async function CategoriesPage() { const email = session?.user.email; if (!email) throw new Error("Not authenticated"); - const [senders, categories, user, progress] = await Promise.all([ + const [senders, categories, emailAccount, progress] = await Promise.all([ prisma.newsletter.findMany({ where: { userId: session.user.id, categoryId: { not: null } }, select: { @@ -45,11 +45,11 @@ export default async function CategoriesPage() { }, }), getUserCategoriesWithRules(session.user.id), - prisma.user.findUnique({ - where: { id: session.user.id }, + prisma.emailAccount.findUnique({ + where: { email }, select: { autoCategorizeSenders: true }, }), - getCategorizationProgress({ userId: session.user.id }), + getCategorizationProgress({ email }), ]); if (!(senders.length > 0 || categories.length > 0)) @@ -132,7 +132,9 @@ export default async function CategoriesPage() { diff --git a/apps/web/app/(app)/usage/usage.tsx b/apps/web/app/(app)/usage/usage.tsx index 320ca5aad5..35b9f90840 100644 --- a/apps/web/app/(app)/usage/usage.tsx +++ b/apps/web/app/(app)/usage/usage.tsx @@ -22,10 +22,10 @@ export function Usage(props: { stats={[ { name: "Unsubscribe Credits", - value: isPremium(data?.premium?.lemonSqueezyRenewsAt || null) + value: isPremium(data?.user.premium?.lemonSqueezyRenewsAt || null) ? "Unlimited" : formatStat( - data?.premium?.unsubscribeCredits ?? + data?.user.premium?.unsubscribeCredits ?? env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS, ), subvalue: "credits", diff --git a/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts b/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts index 88427f85b1..eadcca0c69 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/call-analyze-pattern-api.ts @@ -21,7 +21,7 @@ export async function analyzeSenderPattern(body: AnalyzeSenderPatternBody) { if (!response.ok) { logger.error("Sender pattern analysis API request failed", { - userId: body.userId, + email: body.email, from: body.from, status: response.status, statusText: response.statusText, @@ -29,9 +29,9 @@ export async function analyzeSenderPattern(body: AnalyzeSenderPatternBody) { } } catch (error) { logger.error("Error in sender pattern analysis", { - userId: body.userId, + email: body.email, from: body.from, error: error instanceof Error ? error.message : error, }); } -} \ No newline at end of file +} diff --git a/apps/web/app/api/ai/analyze-sender-pattern/route.ts b/apps/web/app/api/ai/analyze-sender-pattern/route.ts index 51d5b9e2b8..c9d0a622d1 100644 --- a/apps/web/app/api/ai/analyze-sender-pattern/route.ts +++ b/apps/web/app/api/ai/analyze-sender-pattern/route.ts @@ -21,7 +21,7 @@ const MAX_RESULTS = 10; const logger = createScopedLogger("api/ai/pattern-match"); const schema = z.object({ - userId: z.string(), + email: z.string(), from: z.string(), }); export type AnalyzeSenderPatternBody = z.infer; @@ -34,13 +34,13 @@ export const POST = withError(async (request) => { const json = await request.json(); const data = schema.parse(json); - const { userId } = data; + const { email } = data; const from = extractEmailAddress(data.from); - logger.trace("Analyzing sender pattern", { userId, from }); + logger.trace("Analyzing sender pattern", { email, from }); // return immediately and process in background - after(() => process({ userId, from })); + after(() => process({ email, from })); return NextResponse.json({ processing: true }); }); @@ -52,29 +52,34 @@ export const POST = withError(async (request) => { * 4. Detects patterns using AI * 5. Stores patterns in DB for future categorization */ -async function process({ userId, from }: { userId: string; from: string }) { +async function process({ email, from }: { email: string; from: string }) { try { + const emailAccount = await getEmailAccountWithRules({ email }); + + if (!emailAccount) { + logger.error("Email account not found", { email }); + return NextResponse.json({ success: false }, { status: 404 }); + } + // Check if we've already analyzed this sender const existingCheck = await prisma.newsletter.findUnique({ - where: { email_userId: { email: extractEmailAddress(from), userId } }, + where: { + email_userId: { + email: extractEmailAddress(from), + userId: emailAccount.userId, + }, + }, }); if (existingCheck?.patternAnalyzed) { - logger.info("Sender has already been analyzed", { from, userId }); + logger.info("Sender has already been analyzed", { from, email }); return NextResponse.json({ success: true }); } - const user = await getUserWithRules(userId); - - if (!user) { - logger.error("User not found", { userId }); - return NextResponse.json({ success: false }, { status: 404 }); - } - - const account = user.accounts[0]; + const account = emailAccount.account; - if (!account.access_token || !account.refresh_token) { - logger.error("No Gmail account found", { userId }); + if (!account?.access_token || !account?.refresh_token) { + logger.error("No Gmail account found", { email }); return NextResponse.json({ success: false }, { status: 404 }); } @@ -94,7 +99,7 @@ async function process({ userId, from }: { userId: string; from: string }) { if (threadsWithMessages.length === 0) { logger.info("No threads found from this sender", { from, - userId, + email, }); // Don't record a check since we didn't run the AI analysis @@ -109,7 +114,7 @@ async function process({ userId, from }: { userId: string; from: string }) { if (allMessages.length < THRESHOLD_EMAILS) { logger.info("Not enough emails found from this sender", { from, - userId, + email, count: allMessages.length, }); @@ -123,15 +128,8 @@ async function process({ userId, from }: { userId: string; from: string }) { // Detect pattern using AI const patternResult = await aiDetectRecurringPattern({ emails, - user: { - id: user.id, - email: user.email || "", - about: user.about, - aiProvider: user.aiProvider, - aiModel: user.aiModel, - aiApiKey: user.aiApiKey, - }, - rules: user.rules.map((rule) => ({ + user: emailAccount, + rules: emailAccount.user.rules.map((rule) => ({ name: rule.name, instructions: rule.instructions || "", })), @@ -140,20 +138,20 @@ async function process({ userId, from }: { userId: string; from: string }) { if (patternResult?.matchedRule) { // Save pattern to DB (adds sender to rule's group) await saveLearnedPattern({ - userId, + userId: emailAccount.userId, from, ruleName: patternResult.matchedRule, }); } // Record the pattern analysis result - await savePatternCheck(userId, from); + await savePatternCheck({ userId: emailAccount.userId, from }); return NextResponse.json({ success: true }); } catch (error) { logger.error("Error in pattern match API", { from, - userId, + email, error, }); @@ -167,7 +165,10 @@ async function process({ userId, from }: { userId: string; from: string }) { /** * Record that we've analyzed a sender for patterns */ -async function savePatternCheck(userId: string, from: string) { +async function savePatternCheck({ + userId, + from, +}: { userId: string; from: string }) { await prisma.newsletter.upsert({ where: { email_userId: { @@ -292,29 +293,32 @@ async function saveLearnedPattern({ }); } -async function getUserWithRules(userId: string) { - return await prisma.user.findUnique({ - where: { id: userId }, +async function getEmailAccountWithRules({ email }: { email: string }) { + return await prisma.emailAccount.findUnique({ + where: { email }, select: { - id: true, + userId: true, email: true, about: true, aiProvider: true, aiModel: true, aiApiKey: true, - accounts: { - take: 1, + account: { select: { access_token: true, refresh_token: true, }, }, - rules: { - where: { enabled: true, instructions: { not: null } }, + user: { select: { - id: true, - name: true, - instructions: true, + rules: { + where: { enabled: true, instructions: { not: null } }, + select: { + id: true, + name: true, + instructions: true, + }, + }, }, }, }, diff --git a/apps/web/app/api/ai/compose-autocomplete/route.ts b/apps/web/app/api/ai/compose-autocomplete/route.ts index b7f07ec705..6331f6823c 100644 --- a/apps/web/app/api/ai/compose-autocomplete/route.ts +++ b/apps/web/app/api/ai/compose-autocomplete/route.ts @@ -1,23 +1,16 @@ import { NextResponse } from "next/server"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { withError } from "@/utils/middleware"; -import prisma from "@/utils/prisma"; import { composeAutocompleteBody } from "@/app/api/ai/compose-autocomplete/validation"; import { chatCompletionStream } from "@/utils/llms"; +import { getAiUser } from "@/utils/user/get"; export const POST = withError(async (request: Request): Promise => { const session = await auth(); - const userEmail = session?.user.email; - if (!userEmail) return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - aiProvider: true, - aiModel: true, - aiApiKey: true, - }, - }); + const user = await getAiUser({ email }); if (!user) return NextResponse.json({ error: "Not authenticated" }); @@ -32,7 +25,7 @@ Limit your response to no more than 200 characters, but make sure to construct c userAi: user, system, prompt, - userEmail, + userEmail: email, usageLabel: "Compose auto complete", }); diff --git a/apps/web/app/api/ai/models/route.ts b/apps/web/app/api/ai/models/route.ts index 72f530283a..436648b432 100644 --- a/apps/web/app/api/ai/models/route.ts +++ b/apps/web/app/api/ai/models/route.ts @@ -20,19 +20,23 @@ async function getOpenAiModels({ apiKey }: { apiKey: string }) { export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { aiApiKey: true, aiProvider: true }, }); - if (!user || !user.aiApiKey || user.aiProvider !== Provider.OPEN_AI) + if ( + !emailAccount || + !emailAccount.aiApiKey || + emailAccount.aiProvider !== Provider.OPEN_AI + ) return NextResponse.json([]); try { - const result = await getOpenAiModels({ apiKey: user.aiApiKey }); + const result = await getOpenAiModels({ apiKey: emailAccount.aiApiKey }); return NextResponse.json(result); } catch (error) { logger.error("Failed to get OpenAI models", { error }); diff --git a/apps/web/app/api/ai/summarise/controller.ts b/apps/web/app/api/ai/summarise/controller.ts index 0d01d9be5b..160854efe5 100644 --- a/apps/web/app/api/ai/summarise/controller.ts +++ b/apps/web/app/api/ai/summarise/controller.ts @@ -1,12 +1,12 @@ import { chatCompletionStream } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { expire } from "@/utils/redis"; import { saveSummary } from "@/utils/redis/summary"; export async function summarise( text: string, userEmail: string, - userAi: UserAIFields, + userAi: UserEmailWithAI, ) { const system = `You are an email assistant. You summarise emails. Summarise each email in a short ~5 word sentence. diff --git a/apps/web/app/api/ai/summarise/route.ts b/apps/web/app/api/ai/summarise/route.ts index f1682c2904..3e1872b47b 100644 --- a/apps/web/app/api/ai/summarise/route.ts +++ b/apps/web/app/api/ai/summarise/route.ts @@ -12,8 +12,8 @@ import { getAiUser } from "@/utils/user/get"; export const POST = withError(async (request: Request) => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); const json = await request.json(); const body = summariseBody.parse(json); @@ -30,12 +30,12 @@ export const POST = withError(async (request: Request) => { const cachedSummary = await getSummary(prompt); if (cachedSummary) return new NextResponse(cachedSummary); - const userAi = await getAiUser({ id: session.user.id }); + const userAi = await getAiUser({ email }); if (!userAi) return NextResponse.json({ error: "User not found" }, { status: 404 }); - const stream = await summarise(prompt, session.user.email, userAi); + const stream = await summarise(prompt, email, userAi); return stream.toTextStreamResponse(); }); diff --git a/apps/web/app/api/clean/gmail/route.ts b/apps/web/app/api/clean/gmail/route.ts index 198e5e7eb2..3559133447 100644 --- a/apps/web/app/api/clean/gmail/route.ts +++ b/apps/web/app/api/clean/gmail/route.ts @@ -14,7 +14,7 @@ import { updateThread } from "@/utils/redis/clean"; const logger = createScopedLogger("api/clean/gmail"); const cleanGmailSchema = z.object({ - userId: z.string(), + email: z.string(), threadId: z.string(), markDone: z.boolean(), action: z.enum([CleanAction.ARCHIVE, CleanAction.MARK_READ]), @@ -26,7 +26,7 @@ const cleanGmailSchema = z.object({ export type CleanGmailBody = z.infer; async function performGmailAction({ - userId, + email, threadId, markDone, // labelId, @@ -35,18 +35,20 @@ async function performGmailAction({ jobId, action, }: CleanGmailBody) { - const account = await prisma.account.findUnique({ - where: { userId }, - select: { access_token: true, refresh_token: true }, + const account = await prisma.emailAccount.findUnique({ + where: { email }, + select: { + account: { select: { access_token: true, refresh_token: true } }, + }, }); if (!account) throw new SafeError("User not found", 404); - if (!account.access_token || !account.refresh_token) + if (!account.account?.access_token || !account.account?.refresh_token) throw new SafeError("No Gmail account found", 404); const gmail = getGmailClient({ - accessToken: account.access_token, - refreshToken: account.refresh_token, + accessToken: account.account.access_token, + refreshToken: account.account.refresh_token, }); const shouldArchive = markDone && action === CleanAction.ARCHIVE; @@ -72,7 +74,7 @@ async function performGmailAction({ }); await saveCleanResult({ - userId, + email, threadId, markDone, jobId, @@ -80,20 +82,20 @@ async function performGmailAction({ } async function saveCleanResult({ - userId, + email, threadId, markDone, jobId, }: { - userId: string; + email: string; threadId: string; markDone: boolean; jobId: string; }) { await Promise.all([ - updateThread(userId, jobId, threadId, { status: "completed" }), + updateThread({ email, jobId, threadId, update: { status: "completed" } }), saveToDatabase({ - userId, + email, threadId, archive: markDone, jobId, @@ -102,22 +104,22 @@ async function saveCleanResult({ } async function saveToDatabase({ - userId, + email, threadId, archive, jobId, }: { - userId: string; + email: string; threadId: string; archive: boolean; jobId: string; }) { await prisma.cleanupThread.create({ data: { - userId, + user: { connect: { email } }, threadId, archived: archive, - jobId, + job: { connect: { id: jobId } }, }, }); } diff --git a/apps/web/app/api/clean/history/route.ts b/apps/web/app/api/clean/history/route.ts index 0e986d55c0..6647413562 100644 --- a/apps/web/app/api/clean/history/route.ts +++ b/apps/web/app/api/clean/history/route.ts @@ -5,9 +5,9 @@ import { withError } from "@/utils/middleware"; export type CleanHistoryResponse = Awaited>; -async function getCleanHistory({ userId }: { userId: string }) { +async function getCleanHistory({ email }: { email: string }) { const result = await prisma.cleanupJob.findMany({ - where: { userId }, + where: { email }, orderBy: { createdAt: "desc" }, include: { _count: { select: { threads: true } } }, }); @@ -16,10 +16,10 @@ async function getCleanHistory({ userId }: { userId: string }) { export const GET = withError(async () => { const session = await auth(); - if (!session?.user.id) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const result = await getCleanHistory({ userId: session.user.id }); + const result = await getCleanHistory({ email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/clean/route.ts b/apps/web/app/api/clean/route.ts index 7feecf7fed..337497d6ea 100644 --- a/apps/web/app/api/clean/route.ts +++ b/apps/web/app/api/clean/route.ts @@ -24,7 +24,7 @@ import type { ParsedMessage } from "@/utils/types"; const logger = createScopedLogger("api/clean"); const cleanThreadBody = z.object({ - userId: z.string(), + email: z.string(), threadId: z.string(), markedDoneLabelId: z.string(), processedLabelId: z.string(), @@ -44,7 +44,7 @@ const cleanThreadBody = z.object({ export type CleanThreadBody = z.infer; async function cleanThread({ - userId, + email, threadId, markedDoneLabelId, processedLabelId, @@ -58,7 +58,7 @@ async function cleanThread({ // 2. process thread with ai / fixed logic // 3. add to gmail action queue - const user = await getAiUserWithTokens({ id: userId }); + const user = await getAiUserWithTokens({ email }); if (!user) throw new SafeError("User not found", 404); @@ -74,7 +74,7 @@ async function cleanThread({ const messages = await getThreadMessages(threadId, gmail); logger.info("Fetched messages", { - userId, + email, threadId, messageCount: messages.length, }); @@ -82,7 +82,7 @@ async function cleanThread({ const lastMessage = messages[messages.length - 1]; if (!lastMessage) return; - await saveThread(userId, { + await saveThread(email, { threadId, jobId, subject: lastMessage.headers.subject, @@ -92,7 +92,7 @@ async function cleanThread({ }); const publish = getPublish({ - userId, + email, threadId, markedDoneLabelId, processedLabelId, @@ -214,14 +214,14 @@ async function cleanThread({ } function getPublish({ - userId, + email, threadId, markedDoneLabelId, processedLabelId, jobId, action, }: { - userId: string; + email: string; threadId: string; markedDoneLabelId: string; processedLabelId: string; @@ -239,7 +239,7 @@ function getPublish({ const maxRatePerSecond = Math.ceil(12 / actionCount); const cleanGmailBody: CleanGmailBody = { - userId, + email, threadId, markDone, action, @@ -250,7 +250,7 @@ function getPublish({ }; logger.info("Publishing to Qstash", { - userId, + email, threadId, maxRatePerSecond, markDone, @@ -258,17 +258,22 @@ function getPublish({ await Promise.all([ publishToQstash("/api/clean/gmail", cleanGmailBody, { - key: `gmail-action-${userId}`, + key: `gmail-action-${email}`, ratePerSecond: maxRatePerSecond, }), - updateThread(userId, jobId, threadId, { - archive: markDone, - status: "applying", - // label: "", + updateThread({ + email, + jobId, + threadId, + update: { + archive: markDone, + status: "applying", + // label: "", + }, }), ]); - logger.info("Published to Qstash", { userId, threadId }); + logger.info("Published to Qstash", { email, threadId }); }; } diff --git a/apps/web/app/api/clean/save-result.ts b/apps/web/app/api/clean/save-result.ts deleted file mode 100644 index 9c35b95e1b..0000000000 --- a/apps/web/app/api/clean/save-result.ts +++ /dev/null @@ -1,45 +0,0 @@ -import prisma from "@/utils/prisma"; -import { updateThread } from "@/utils/redis/clean"; - -export async function saveCleanResult({ - userId, - threadId, - markDone, - jobId, -}: { - userId: string; - threadId: string; - markDone: boolean; - jobId: string; -}) { - await Promise.all([ - updateThread(userId, jobId, threadId, { status: "completed" }), - saveToDatabase({ - userId, - threadId, - archive: markDone, - jobId, - }), - ]); -} - -async function saveToDatabase({ - userId, - threadId, - archive, - jobId, -}: { - userId: string; - threadId: string; - archive: boolean; - jobId: string; -}) { - await prisma.cleanupThread.create({ - data: { - userId, - threadId, - archived: archive, - jobId, - }, - }); -} diff --git a/apps/web/app/api/google/watch/all/route.ts b/apps/web/app/api/google/watch/all/route.ts index a33fbfb509..6744e686a2 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -14,26 +14,32 @@ export const dynamic = "force-dynamic"; export const maxDuration = 300; async function watchAllEmails() { - const premiums = await prisma.premium.findMany({ + const emailAccounts = await prisma.emailAccount.findMany({ where: { - lemonSqueezyRenewsAt: { gt: new Date() }, + user: { + premium: { + lemonSqueezyRenewsAt: { gt: new Date() }, + }, + }, }, select: { - tier: true, - coldEmailBlockerAccess: true, - aiAutomationAccess: true, - users: { + email: true, + aiApiKey: true, + watchEmailsExpirationDate: true, + account: { + select: { + access_token: true, + refresh_token: true, + expires_at: true, + providerAccountId: true, + }, + }, + user: { select: { - id: true, - email: true, - aiApiKey: true, - watchEmailsExpirationDate: true, - accounts: { + premium: { select: { - access_token: true, - refresh_token: true, - expires_at: true, - providerAccountId: true, + aiAutomationAccess: true, + coldEmailBlockerAccess: true, }, }, }, @@ -41,50 +47,49 @@ async function watchAllEmails() { }, }); - const users = premiums - .flatMap((premium) => premium.users.map((user) => ({ ...user, premium }))) - .sort((a, b) => { - // Prioritize null dates first - if (!a.watchEmailsExpirationDate && b.watchEmailsExpirationDate) - return -1; - if (a.watchEmailsExpirationDate && !b.watchEmailsExpirationDate) return 1; - - // If both have dates, sort by earliest date first - if (a.watchEmailsExpirationDate && b.watchEmailsExpirationDate) { - return ( - new Date(a.watchEmailsExpirationDate).getTime() - - new Date(b.watchEmailsExpirationDate).getTime() - ); - } + logger.info("Watching emails for users", { + count: emailAccounts.length, + }); + + const sortedEmailAccounts = emailAccounts.sort((a, b) => { + // Prioritize null dates first + if (!a.watchEmailsExpirationDate && b.watchEmailsExpirationDate) return -1; + if (a.watchEmailsExpirationDate && !b.watchEmailsExpirationDate) return 1; - return 0; - }); + // If both have dates, sort by earliest date first + if (a.watchEmailsExpirationDate && b.watchEmailsExpirationDate) { + return ( + new Date(a.watchEmailsExpirationDate).getTime() - + new Date(b.watchEmailsExpirationDate).getTime() + ); + } - logger.info("Watching emails for users", { count: users.length }); + return 0; + }); - for (const user of users) { + for (const emailAccount of sortedEmailAccounts) { try { - logger.info("Watching emails for user", { email: user.email }); + logger.info("Watching emails for user", { email: emailAccount.email }); const userHasAiAccess = hasAiAccess( - user.premium.aiAutomationAccess, - user.aiApiKey, + emailAccount.user.premium?.aiAutomationAccess, + emailAccount.aiApiKey, ); const userHasColdEmailAccess = hasColdEmailAccess( - user.premium.coldEmailBlockerAccess, - user.aiApiKey, + emailAccount.user.premium?.coldEmailBlockerAccess, + emailAccount.aiApiKey, ); if (!userHasAiAccess && !userHasColdEmailAccess) { logger.info("User does not have access to AI or cold email", { - email: user.email, + email: emailAccount.email, }); if ( - user.watchEmailsExpirationDate && - new Date(user.watchEmailsExpirationDate) < new Date() + emailAccount.watchEmailsExpirationDate && + new Date(emailAccount.watchEmailsExpirationDate) < new Date() ) { - prisma.user.update({ - where: { id: user.id }, + prisma.emailAccount.update({ + where: { email: emailAccount.email }, data: { watchEmailsExpirationDate: null }, }); } @@ -102,30 +107,31 @@ async function watchAllEmails() { // continue; // } - const account = user.accounts[0]; - - if (!account.access_token || !account.refresh_token) { + if ( + !emailAccount.account?.access_token || + !emailAccount.account?.refresh_token + ) { logger.info("User has no access token or refresh token", { - email: user.email, + email: emailAccount.email, }); continue; } const gmail = await getGmailClientWithRefresh( { - accessToken: account.access_token, - refreshToken: account.refresh_token, - expiryDate: account.expires_at, + accessToken: emailAccount.account.access_token, + refreshToken: emailAccount.account.refresh_token, + expiryDate: emailAccount.account.expires_at, }, - account.providerAccountId, + emailAccount.account.providerAccountId, ); // couldn't refresh the token if (!gmail) continue; - await watchEmails(user.id, gmail); + await watchEmails({ email: emailAccount.email, gmail }); } catch (error) { - logger.error("Error for user", { userId: user.id, error }); + logger.error("Error for user", { userId: emailAccount.email, error }); } } diff --git a/apps/web/app/api/google/watch/controller.ts b/apps/web/app/api/google/watch/controller.ts index 061bb3a5a2..4628086ba0 100644 --- a/apps/web/app/api/google/watch/controller.ts +++ b/apps/web/app/api/google/watch/controller.ts @@ -7,18 +7,24 @@ import { watchGmail, unwatchGmail } from "@/utils/gmail/watch"; const logger = createScopedLogger("google/watch"); -export async function watchEmails(userId: string, gmail: gmail_v1.Gmail) { +export async function watchEmails({ + email, + gmail, +}: { + email: string; + gmail: gmail_v1.Gmail; +}) { const res = await watchGmail(gmail); if (res.expiration) { const expirationDate = new Date(+res.expiration); - await prisma.user.update({ - where: { id: userId }, + await prisma.emailAccount.update({ + where: { email }, data: { watchEmailsExpirationDate: expirationDate }, }); return expirationDate; } - logger.error("Error watching inbox", { userId }); + logger.error("Error watching inbox", { email }); } async function unwatch(gmail: gmail_v1.Gmail) { @@ -27,11 +33,11 @@ async function unwatch(gmail: gmail_v1.Gmail) { } export async function unwatchEmails({ - userId, + email, access_token, refresh_token, }: { - userId: string; + email: string; access_token: string | null; refresh_token: string | null; }) { @@ -43,16 +49,16 @@ export async function unwatchEmails({ await unwatch(gmail); } catch (error) { if (error instanceof Error && error.message.includes("invalid_grant")) { - logger.warn("Error unwatching emails, invalid grant", { userId }); + logger.warn("Error unwatching emails, invalid grant", { email }); return; } - logger.error("Error unwatching emails", { userId, error }); + logger.error("Error unwatching emails", { email, error }); captureException(error); } - await prisma.user.update({ - where: { id: userId }, + await prisma.emailAccount.update({ + where: { email }, data: { watchEmailsExpirationDate: null }, }); } diff --git a/apps/web/app/api/google/watch/route.ts b/apps/web/app/api/google/watch/route.ts index 090e9de42d..68c2023b05 100644 --- a/apps/web/app/api/google/watch/route.ts +++ b/apps/web/app/api/google/watch/route.ts @@ -11,15 +11,16 @@ const logger = createScopedLogger("api/google/watch"); export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); const gmail = getGmailClient(session); - const expirationDate = await watchEmails(session.user.id, gmail); - if (expirationDate) { - return NextResponse.json({ expirationDate }); - } - logger.error("Error watching inbox", { userId: session.user.id }); + const expirationDate = await watchEmails({ email, gmail }); + + if (expirationDate) return NextResponse.json({ expirationDate }); + + logger.error("Error watching inbox", { email }); + return NextResponse.json({ error: "Error watching inbox" }); }); diff --git a/apps/web/app/api/google/webhook/process-history-item.ts b/apps/web/app/api/google/webhook/process-history-item.ts index 13b1cbb785..5655ac3559 100644 --- a/apps/web/app/api/google/webhook/process-history-item.ts +++ b/apps/web/app/api/google/webhook/process-history-item.ts @@ -65,7 +65,11 @@ export async function processHistoryItem( getMessage(messageId, gmail, "full"), prisma.executedRule.findUnique({ where: { - unique_user_thread_message: { userId: user.id, threadId, messageId }, + unique_user_thread_message: { + userId: user.userId, + threadId, + messageId, + }, }, select: { id: true }, }), @@ -94,7 +98,7 @@ export async function processHistoryItem( return processAssistantEmail({ message, userEmail, - userId: user.id, + userId: user.userId, gmail, }); } @@ -119,7 +123,7 @@ export async function processHistoryItem( // check if unsubscribed const blocked = await blockUnsubscribedEmails({ from: message.headers.from, - userId: user.id, + userId: user.userId, gmail, messageId, }); @@ -163,7 +167,7 @@ export async function processHistoryItem( if (user.autoCategorizeSenders) { const sender = extractEmailAddress(message.headers.from); const existingSender = await prisma.newsletter.findUnique({ - where: { email_userId: { email: sender, userId: user.id } }, + where: { email_userId: { email: sender, userId: user.userId } }, select: { category: true }, }); if (!existingSender?.category) { @@ -213,7 +217,7 @@ async function handleOutbound( // The individual functions handle their own operational errors. const [trackingResult, outboundResult] = await Promise.allSettled([ trackSentDraftStatus({ - user: { id: user.id, email: user.email }, + user: { id: user.userId, email: user.email }, message, gmail, }), @@ -239,7 +243,7 @@ async function handleOutbound( try { await cleanupThreadAIDrafts({ threadId: message.threadId, - userId: user.id, + userId: user.userId, gmail, }); } catch (cleanupError) { diff --git a/apps/web/app/api/google/webhook/process-history.ts b/apps/web/app/api/google/webhook/process-history.ts index dc2580bf72..7a7456fc08 100644 --- a/apps/web/app/api/google/webhook/process-history.ts +++ b/apps/web/app/api/google/webhook/process-history.ts @@ -25,28 +25,33 @@ export async function processHistoryForUser( // So we need to convert it to lowercase const email = emailAddress.toLowerCase(); - const account = await prisma.account.findFirst({ - where: { user: { email }, provider: "google" }, + const emailAccount = await prisma.emailAccount.findFirst({ + where: { email }, select: { - access_token: true, - refresh_token: true, - expires_at: true, - providerAccountId: true, + email: true, userId: true, + about: true, + lastSyncedHistoryId: true, + coldEmailBlocker: true, + coldEmailPrompt: true, + aiProvider: true, + aiModel: true, + aiApiKey: true, + autoCategorizeSenders: true, + account: { + select: { + access_token: true, + refresh_token: true, + expires_at: true, + providerAccountId: true, + }, + }, user: { select: { - email: true, - about: true, - lastSyncedHistoryId: true, rules: { where: { enabled: true }, include: { actions: true, categoryFilters: true }, }, - coldEmailBlocker: true, - coldEmailPrompt: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, premium: { select: { lemonSqueezyRenewsAt: true, @@ -54,73 +59,79 @@ export async function processHistoryForUser( aiAutomationAccess: true, }, }, - autoCategorizeSenders: true, }, }, }, }); - if (!account) { + if (!emailAccount) { logger.error("Account not found", { email }); return NextResponse.json({ ok: true }); } - const premium = isPremium(account.user.premium?.lemonSqueezyRenewsAt || null) - ? account.user.premium + const premium = isPremium( + emailAccount.user.premium?.lemonSqueezyRenewsAt || null, + ) + ? emailAccount.user.premium : undefined; if (!premium) { logger.info("Account not premium", { email, - lemonSqueezyRenewsAt: account.user.premium?.lemonSqueezyRenewsAt, + lemonSqueezyRenewsAt: emailAccount.user.premium?.lemonSqueezyRenewsAt, + }); + await unwatchEmails({ + email: emailAccount.email, + access_token: emailAccount.account?.access_token ?? null, + refresh_token: emailAccount.account?.refresh_token ?? null, }); - await unwatchEmails(account); return NextResponse.json({ ok: true }); } const userHasAiAccess = hasAiAccess( premium.aiAutomationAccess, - account.user.aiApiKey, + emailAccount.aiApiKey, ); const userHasColdEmailAccess = hasColdEmailAccess( premium.coldEmailBlockerAccess, - account.user.aiApiKey, + emailAccount.aiApiKey, ); if (!userHasAiAccess && !userHasColdEmailAccess) { logger.trace("Does not have hasAiOrColdEmailAccess", { email }); - await unwatchEmails(account); + await unwatchEmails({ + email: emailAccount.email, + access_token: emailAccount.account?.access_token ?? null, + refresh_token: emailAccount.account?.refresh_token ?? null, + }); return NextResponse.json({ ok: true }); } - const hasAutomationRules = account.user.rules.length > 0; + const hasAutomationRules = emailAccount.user.rules.length > 0; const shouldBlockColdEmails = - account.user.coldEmailBlocker && - account.user.coldEmailBlocker !== ColdEmailSetting.DISABLED; + emailAccount.coldEmailBlocker && + emailAccount.coldEmailBlocker !== ColdEmailSetting.DISABLED; if (!hasAutomationRules && !shouldBlockColdEmails) { logger.trace("Has no rules set and cold email blocker disabled", { email }); return NextResponse.json({ ok: true }); } - if (!account.access_token || !account.refresh_token) { + if ( + !emailAccount.account?.access_token || + !emailAccount.account?.refresh_token + ) { logger.error("Missing access or refresh token", { email }); return NextResponse.json({ ok: true }); } - if (!account.user.email) { - // shouldn't ever happen - logger.error("Missing user email", { email }); - return NextResponse.json({ ok: true }); - } - try { const gmail = await getGmailClientWithRefresh( { - accessToken: account.access_token, - refreshToken: account.refresh_token, - expiryDate: account.expires_at, + accessToken: emailAccount.account?.access_token, + refreshToken: emailAccount.account?.refresh_token, + expiryDate: emailAccount.account?.expires_at, }, - account.providerAccountId, + emailAccount.account?.providerAccountId, ); // couldn't refresh the token @@ -132,13 +143,13 @@ export async function processHistoryForUser( const startHistoryId = options?.startHistoryId || Math.max( - Number.parseInt(account.user.lastSyncedHistoryId || "0"), + Number.parseInt(emailAccount.lastSyncedHistoryId || "0"), historyId - 500, // avoid going too far back ).toString(); logger.info("Listing history", { startHistoryId, - lastSyncedHistoryId: account.user.lastSyncedHistoryId, + lastSyncedHistoryId: emailAccount.lastSyncedHistoryId, gmailHistoryId: startHistoryId, email, }); @@ -162,22 +173,12 @@ export async function processHistoryForUser( history: history.history, email, gmail, - accessToken: account.access_token, + accessToken: emailAccount.account?.access_token, hasAutomationRules, - rules: account.user.rules, + rules: emailAccount.user.rules, hasColdEmailAccess: userHasColdEmailAccess, hasAiAutomationAccess: userHasAiAccess, - user: { - id: account.userId, - email: account.user.email, - about: account.user.about || "", - aiProvider: account.user.aiProvider, - aiModel: account.user.aiModel, - aiApiKey: account.user.aiApiKey, - coldEmailPrompt: account.user.coldEmailPrompt, - coldEmailBlocker: account.user.coldEmailBlocker, - autoCategorizeSenders: account.user.autoCategorizeSenders, - }, + user: emailAccount, }); } else { logger.info("No history", { @@ -186,7 +187,7 @@ export async function processHistoryForUser( }); // important to save this or we can get into a loop with never receiving history - await updateLastSyncedHistoryId(account.user.email, historyId.toString()); + await updateLastSyncedHistoryId(emailAccount.email, historyId.toString()); } logger.info("Completed processing history", { decodedData }); @@ -264,7 +265,7 @@ async function updateLastSyncedHistoryId( lastSyncedHistoryId?: string | null, ) { if (!lastSyncedHistoryId) return; - await prisma.user.update({ + await prisma.emailAccount.update({ where: { email }, data: { lastSyncedHistoryId }, }); diff --git a/apps/web/app/api/google/webhook/types.ts b/apps/web/app/api/google/webhook/types.ts index 331ad166fb..5cbcec2abf 100644 --- a/apps/web/app/api/google/webhook/types.ts +++ b/apps/web/app/api/google/webhook/types.ts @@ -1,7 +1,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import type { RuleWithActionsAndCategories } from "@/utils/types"; import type { UserEmailWithAI } from "@/utils/llms/types"; -import type { User } from "@prisma/client"; +import type { EmailAccount, User } from "@prisma/client"; export type ProcessHistoryOptions = { history: gmail_v1.Schema$History[]; @@ -13,8 +13,8 @@ export type ProcessHistoryOptions = { hasColdEmailAccess: boolean; hasAiAutomationAccess: boolean; user: Pick< - User, - "about" | "coldEmailPrompt" | "coldEmailBlocker" | "autoCategorizeSenders" + EmailAccount, + "coldEmailPrompt" | "coldEmailBlocker" | "autoCategorizeSenders" > & UserEmailWithAI; }; diff --git a/apps/web/app/api/reply-tracker/process-previous/route.ts b/apps/web/app/api/reply-tracker/process-previous/route.ts index 9ba0e270b0..e0977b5162 100644 --- a/apps/web/app/api/reply-tracker/process-previous/route.ts +++ b/apps/web/app/api/reply-tracker/process-previous/route.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { withError } from "@/utils/middleware"; import { processPreviousSentEmails } from "@/utils/reply-tracker/check-previous-emails"; @@ -6,13 +7,12 @@ import { getGmailClient } from "@/utils/gmail/client"; import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; import { isValidInternalApiKey } from "@/utils/internal-api"; -import { headers } from "next/headers"; const logger = createScopedLogger("api/reply-tracker/process-previous"); export const maxDuration = 300; -const processPreviousSchema = z.object({ userId: z.string() }); +const processPreviousSchema = z.object({ email: z.string() }); export type ProcessPreviousBody = z.infer; export const POST = withError(async (request: Request) => { @@ -23,31 +23,29 @@ export const POST = withError(async (request: Request) => { const json = await request.json(); const body = processPreviousSchema.parse(json); + const email = body.email; - const user = await prisma.user.findUnique({ - where: { id: body.userId }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, include: { - accounts: { - where: { provider: "google" }, + account: { select: { access_token: true, refresh_token: true }, }, }, }); - if (!user) return NextResponse.json({ error: "User not found" }); + if (!emailAccount) return NextResponse.json({ error: "User not found" }); - logger.info("Processing previous emails for user", { userId: user.id }); + logger.info("Processing previous emails for user", { email }); - const account = user.accounts[0]; - if (!account) return NextResponse.json({ error: "No Google account found" }); - if (!account.access_token) + if (!emailAccount.account?.access_token) return NextResponse.json({ error: "No access token or refresh token" }); const gmail = getGmailClient({ - accessToken: account.access_token, - refreshToken: account.refresh_token ?? undefined, + accessToken: emailAccount.account.access_token, + refreshToken: emailAccount.account.refresh_token ?? undefined, }); - await processPreviousSentEmails(gmail, user); + await processPreviousSentEmails(gmail, emailAccount); return NextResponse.json({ success: true }); }); diff --git a/apps/web/app/api/resend/summary/all/route.ts b/apps/web/app/api/resend/summary/all/route.ts index d24969a5b3..837e5440da 100644 --- a/apps/web/app/api/resend/summary/all/route.ts +++ b/apps/web/app/api/resend/summary/all/route.ts @@ -21,16 +21,18 @@ export const maxDuration = 300; async function sendSummaryAllUpdate() { logger.info("Sending summary all update"); - const users = await prisma.user.findMany({ + const emailAccounts = await prisma.emailAccount.findMany({ select: { email: true }, where: { summaryEmailFrequency: { not: Frequency.NEVER, }, // Only send to premium users - premium: { - lemonSqueezyRenewsAt: { - gt: new Date(), + user: { + premium: { + lemonSqueezyRenewsAt: { + gt: new Date(), + }, }, }, // User at least 4 days old @@ -40,29 +42,29 @@ async function sendSummaryAllUpdate() { }, }); - logger.info("Sending summary to users", { count: users.length }); + logger.info("Sending summary to users", { count: emailAccounts.length }); const url = `${env.NEXT_PUBLIC_BASE_URL}/api/resend/summary`; - for (const user of users) { + for (const emailAccount of emailAccounts) { try { await publishToQstashQueue({ queueName: "email-summary-all", parallelism: 3, // Allow up to 3 concurrent jobs from this queue url, - body: { email: user.email }, + body: { email: emailAccount.email }, headers: getCronSecretHeader(), }); } catch (error) { logger.error("Failed to publish to Qstash", { - email: user.email, + email: emailAccount.email, error, }); } } - logger.info("All requests initiated", { count: users.length }); - return { count: users.length }; + logger.info("All requests initiated", { count: emailAccounts.length }); + return { count: emailAccounts.length }; } export const GET = withError(async (request) => { diff --git a/apps/web/app/api/resend/summary/route.ts b/apps/web/app/api/resend/summary/route.ts index 30c24f6cc6..4ff3cf507d 100644 --- a/apps/web/app/api/resend/summary/route.ts +++ b/apps/web/app/api/resend/summary/route.ts @@ -30,18 +30,31 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { const days = 7; const cutOffDate = subHours(new Date(), days * 24 + 1); + if (!force) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, + select: { lastSummaryEmailAt: true }, + }); + + if (!emailAccount) { + logger.error("Email account not found", loggerOptions); + return { success: true }; + } + + const lastSummaryEmailAt = emailAccount.lastSummaryEmailAt; + + if (lastSummaryEmailAt && lastSummaryEmailAt > cutOffDate) { + logger.info("Last summary email was recent", { + ...loggerOptions, + lastSummaryEmailAt, + cutOffDate, + }); + return { success: true }; + } + } + const user = await prisma.user.findUnique({ - where: { - email, - ...(force - ? {} - : { - OR: [ - { lastSummaryEmailAt: { lt: cutOffDate } }, - { lastSummaryEmailAt: null }, - ], - }), - }, + where: { email }, select: { id: true, coldEmails: { where: { createdAt: { gt: cutOffDate } } }, @@ -225,7 +238,7 @@ async function sendEmail({ email, force }: { email: string; force?: boolean }) { await Promise.all([ shouldSendEmail ? sendEmail(user.id) : Promise.resolve(), - prisma.user.update({ + prisma.emailAccount.update({ where: { email }, data: { lastSummaryEmailAt: new Date() }, }), diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts index a6cf7f81b3..7edce074b4 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch-validation.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const aiCategorizeSendersSchema = z.object({ - userId: z.string(), + email: z.string(), senders: z.array(z.string()), }); export type AiCategorizeSenders = z.infer; diff --git a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts index 2344adf91c..e9ae1988cc 100644 --- a/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts +++ b/apps/web/app/api/user/categorize/senders/batch/handle-batch.ts @@ -36,23 +36,20 @@ export async function handleBatchRequest( async function handleBatchInternal(request: Request) { const json = await request.json(); const body = aiCategorizeSendersSchema.parse(json); - const { userId, senders } = body; + const { email, senders } = body; - logger.trace("Handle batch request", { - userId, - senders: senders.length, - }); + logger.trace("Handle batch request", { email, senders: senders.length }); - const userResult = await validateUserAndAiAccess(userId); + const userResult = await validateUserAndAiAccess({ email }); if (isActionError(userResult)) return userResult; - const { user } = userResult; + const { emailAccount } = userResult; - const categoriesResult = await getCategories(userId); + const categoriesResult = await getCategories(emailAccount.userId); if (isActionError(categoriesResult)) return categoriesResult; const { categories } = categoriesResult; - const account = await prisma.account.findFirst({ - where: { userId, provider: "google" }, + const account = await prisma.account.findUnique({ + where: { email }, select: { access_token: true, refresh_token: true, @@ -91,7 +88,7 @@ async function handleBatchInternal(request: Request) { // 2. categorize senders with ai const results = await categorizeWithAi({ - user, + user: emailAccount, sendersWithEmails, categories, }); @@ -102,7 +99,7 @@ async function handleBatchInternal(request: Request) { sender: result.sender, categories, categoryName: result.category ?? UNKNOWN_CATEGORY, - userId, + userId: emailAccount.userId, }); } @@ -132,7 +129,7 @@ async function handleBatchInternal(request: Request) { // } await saveCategorizationProgress({ - userId, + email, incrementCompleted: senders.length, }); diff --git a/apps/web/app/api/user/categorize/senders/progress/route.ts b/apps/web/app/api/user/categorize/senders/progress/route.ts index 9ad94fb887..50d3803950 100644 --- a/apps/web/app/api/user/categorize/senders/progress/route.ts +++ b/apps/web/app/api/user/categorize/senders/progress/route.ts @@ -7,15 +7,16 @@ export type CategorizeProgress = Awaited< ReturnType >; -async function getCategorizeProgress(userId: string) { - const progress = await getCategorizationProgress({ userId }); +async function getCategorizeProgress({ email }: { email: string }) { + const progress = await getCategorizationProgress({ email }); return progress; } export const GET = withError(async () => { const session = await auth(); - if (!session?.user) return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const result = await getCategorizeProgress(session.user.id); + const result = await getCategorizeProgress({ email }); return NextResponse.json(result); }); diff --git a/apps/web/app/api/user/me/route.ts b/apps/web/app/api/user/me/route.ts index dbb9439ac5..bb00bca4f5 100644 --- a/apps/web/app/api/user/me/route.ts +++ b/apps/web/app/api/user/me/route.ts @@ -6,11 +6,11 @@ import { SafeError } from "@/utils/error"; export type UserResponse = Awaited>; -async function getUser(userId: string) { - const user = await prisma.user.findUnique({ - where: { id: userId }, +async function getUser({ email }: { email: string }) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { - id: true, + userId: true, aiProvider: true, aiModel: true, aiApiKey: true, @@ -18,35 +18,39 @@ async function getUser(userId: string) { summaryEmailFrequency: true, coldEmailBlocker: true, coldEmailPrompt: true, - premium: { + user: { select: { - lemonSqueezyCustomerId: true, - lemonSqueezySubscriptionId: true, - lemonSqueezyRenewsAt: true, - unsubscribeCredits: true, - bulkUnsubscribeAccess: true, - aiAutomationAccess: true, - coldEmailBlockerAccess: true, - tier: true, - emailAccountsAccess: true, - lemonLicenseKey: true, - pendingInvites: true, + premium: { + select: { + lemonSqueezyCustomerId: true, + lemonSqueezySubscriptionId: true, + lemonSqueezyRenewsAt: true, + unsubscribeCredits: true, + bulkUnsubscribeAccess: true, + aiAutomationAccess: true, + coldEmailBlockerAccess: true, + tier: true, + emailAccountsAccess: true, + lemonLicenseKey: true, + pendingInvites: true, + }, + }, }, }, }, }); - if (!user) throw new SafeError("User not found"); + if (!emailAccount) throw new SafeError("User not found"); - return user; + return emailAccount; } export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const user = await getUser(session.user.id); + const user = await getUser({ email }); return NextResponse.json(user); }); diff --git a/apps/web/app/api/user/rules/prompt/route.ts b/apps/web/app/api/user/rules/prompt/route.ts index 2360ea79d2..8a6a15043d 100644 --- a/apps/web/app/api/user/rules/prompt/route.ts +++ b/apps/web/app/api/user/rules/prompt/route.ts @@ -5,19 +5,19 @@ import prisma from "@/utils/prisma"; export type RulesPromptResponse = Awaited>; -async function getRulesPrompt(options: { userId: string }) { - return await prisma.user.findUnique({ - where: { id: options.userId }, +async function getRulesPrompt(options: { email: string }) { + return await prisma.emailAccount.findUnique({ + where: { email: options.email }, select: { rulesPrompt: true }, }); } export const GET = withError(async () => { const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); + const email = session?.user.email; + if (!email) return NextResponse.json({ error: "Not authenticated" }); - const result = await getRulesPrompt({ userId: session.user.id }); + const result = await getRulesPrompt({ email }); return NextResponse.json(result); }); diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index ab43ade74f..fe56b57a2f 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -20,7 +20,7 @@ export function usePremium() { const swrResponse = useUser(); const { data } = swrResponse; - const premium = data?.premium; + const premium = data?.user.premium; const aiApiKey = data?.aiApiKey; const isUserPremium = !!(premium && isPremium(premium.lemonSqueezyRenewsAt)); diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 4478550ed6..a535112316 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -25,9 +25,9 @@ model Account { id_token String? @db.Text session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - emailAccountId String? @unique - emailAccount EmailAccount? @relation(fields: [emailAccountId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + email String? @unique + emailAccount EmailAccount? @relation(fields: [email], references: [email]) @@unique([provider, providerAccountId]) } @@ -53,33 +53,33 @@ model User { sessions Session[] // additional fields - about String? - signature String? - watchEmailsExpirationDate DateTime? - lastSyncedHistoryId String? - completedOnboardingAt DateTime? // questions about the user. e.g. their role - completedAppOnboardingAt DateTime? // how to use the app - onboardingAnswers Json? - behaviorProfile Json? - lastLogin DateTime? - utms Json? - errorMessages Json? // eg. user set incorrect AI API key + // about String? + // signature String? + // watchEmailsExpirationDate DateTime? + // lastSyncedHistoryId String? + completedOnboardingAt DateTime? // questions about the user. e.g. their role + completedAppOnboardingAt DateTime? // how to use the app + onboardingAnswers Json? + // behaviorProfile Json? + lastLogin DateTime? + utms Json? + errorMessages Json? // eg. user set incorrect AI API key // settings - aiProvider String? - aiModel String? - aiApiKey String? - statsEmailFrequency Frequency @default(WEEKLY) - summaryEmailFrequency Frequency @default(WEEKLY) - lastSummaryEmailAt DateTime? - coldEmailBlocker ColdEmailSetting? - coldEmailPrompt String? - rulesPrompt String? - webhookSecret String? - outboundReplyTracking Boolean @default(false) + // aiProvider String? + // aiModel String? + // aiApiKey String? + // statsEmailFrequency Frequency @default(WEEKLY) + // summaryEmailFrequency Frequency @default(WEEKLY) + // lastSummaryEmailAt DateTime? + // coldEmailBlocker ColdEmailSetting? + // coldEmailPrompt String? + // rulesPrompt String? + // webhookSecret String? + // outboundReplyTracking Boolean @default(false) // categorization - autoCategorizeSenders Boolean @default(false) + // autoCategorizeSenders Boolean @default(false) // premium can be shared among multiple users premiumId String? @@ -104,19 +104,37 @@ model User { knowledges Knowledge[] emailAccounts EmailAccount[] - @@index([lastSummaryEmailAt]) + // @@index([lastSummaryEmailAt]) } // Migrating over to the new settings model. Currently most settings are in the User model, but will be moved to this model in the future. model EmailAccount { - id String @id @default(cuid()) + // id String @id @default(cuid()) + email String @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - email String @unique writingStyle String? + about String? + signature String? + watchEmailsExpirationDate DateTime? + lastSyncedHistoryId String? + behaviorProfile Json? + + aiProvider String? + aiModel String? + aiApiKey String? + statsEmailFrequency Frequency @default(WEEKLY) + summaryEmailFrequency Frequency @default(WEEKLY) + lastSummaryEmailAt DateTime? + coldEmailBlocker ColdEmailSetting? + coldEmailPrompt String? + rulesPrompt String? + webhookSecret String? + outboundReplyTracking Boolean @default(false) + autoCategorizeSenders Boolean @default(false) + // To add at a later date: - // about String? // signature String? // ... @@ -124,6 +142,10 @@ model EmailAccount { user User @relation(fields: [userId], references: [id], onDelete: Cascade) accountId String @unique account Account? + + cleanupJobs CleanupJob[] + + @@index([lastSummaryEmailAt]) } model Premium { @@ -447,6 +469,8 @@ model CleanupJob { skipConversation Boolean? userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) + email String + emailAccount EmailAccount @relation(fields: [email], references: [email], onDelete: Cascade) threads CleanupThread[] } diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index c811782a6e..ae54db0b14 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -57,22 +57,8 @@ export const runRulesAction = withActionInstrumentation( if (isActionError(sessionResult)) return sessionResult; const { gmail, user: u } = sessionResult; - const user = await prisma.user.findUnique({ - where: { id: u.id }, - select: { - id: true, - email: true, - about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, - rules: { - where: { enabled: true }, - include: { actions: true, categoryFilters: true }, - }, - }, - }); - if (!user?.email) return { error: "User email not found" }; + const emailAccount = await getEmailAccountWithRules({ email: u.email }); + if (!emailAccount) return { error: "Email account not found" }; const fetchExecutedRule = !isTest && !rerun; @@ -82,7 +68,7 @@ export const runRulesAction = withActionInstrumentation( ? prisma.executedRule.findUnique({ where: { unique_user_thread_message: { - userId: user.id, + userId: emailAccount.userId, threadId, messageId, }, @@ -99,7 +85,7 @@ export const runRulesAction = withActionInstrumentation( if (executedRule) { logger.info("Skipping. Rule already exists.", { - email: user.email, + email: emailAccount.email, messageId, threadId, }); @@ -119,8 +105,8 @@ export const runRulesAction = withActionInstrumentation( isTest, gmail, message, - rules: user.rules, - user: { ...user, email: user.email }, + rules: emailAccount.user.rules, + user: emailAccount, }); return result; @@ -136,25 +122,13 @@ export const testAiCustomContentAction = withActionInstrumentation( const { content } = data; const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; const gmail = getGmailClient(session); - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { - id: true, - email: true, - about: true, - aiProvider: true, - aiModel: true, - aiApiKey: true, - rules: { - where: { enabled: true }, - include: { actions: true, categoryFilters: true }, - }, - }, + const emailAccount = await getEmailAccountWithRules({ + email: session.user.email, }); - if (!user) return { error: "User not found" }; + if (!emailAccount) return { error: "Email account not found" }; const result = await runRules({ isTest: true, @@ -174,8 +148,8 @@ export const testAiCustomContentAction = withActionInstrumentation( inline: [], internalDate: new Date().toISOString(), }, - rules: user.rules, - user, + rules: emailAccount.user.rules, + user: emailAccount, }); return result; @@ -188,25 +162,28 @@ export const createAutomationAction = withActionInstrumentation< { existingRuleId?: string } >("createAutomation", async ({ prompt }: { prompt: string }) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; if (!session.accessToken) return { error: "No access token" }; - const user = await getAiUser({ id: userId }); - if (!user) return { error: "User not found" }; - if (!user.email) return { error: "User email not found" }; + const emailAccount = await getEmailAccountWithRules({ + email: session.user.email, + }); + if (!emailAccount) return { error: "Email account not found" }; let result: CreateOrUpdateRuleSchemaWithCategories; try { - result = await aiCreateRule(prompt, user, user.email); + result = await aiCreateRule(prompt, emailAccount); } catch (error: any) { return { error: `AI error creating rule. ${error.message}` }; } if (!result) return { error: "AI error creating rule." }; - return await safeCreateRule(result, userId, null); + return await safeCreateRule({ + result, + userId: emailAccount.userId, + }); }); export const setRuleRunOnThreadsAction = withActionInstrumentation( @@ -288,9 +265,10 @@ export const saveRulesPromptAction = withActionInstrumentation( "saveRulesPrompt", async (unsafeData: SaveRulesPromptBody) => { const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; if (!session.accessToken) return { error: "No access token" }; - setUser({ email: session.user.email }); + setUser({ email }); logger.info("Starting saveRulesPromptAction", { email: session.user.email, @@ -305,37 +283,37 @@ export const saveRulesPromptAction = withActionInstrumentation( return { error: error.message }; } - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { rulesPrompt: true, aiProvider: true, aiModel: true, aiApiKey: true, email: true, - categories: { select: { id: true, name: true } }, + userId: true, + about: true, + user: { + select: { + categories: { select: { id: true, name: true } }, + }, + }, }, }); - if (!user) { - logger.error("User not found"); - return { error: "User not found" }; - } - if (!user.email) { - logger.error("User email not found"); - return { error: "User email not found" }; + if (!emailAccount) { + logger.error("Email account not found"); + return { error: "Email account not found" }; } - const oldPromptFile = user.rulesPrompt; + const oldPromptFile = emailAccount.rulesPrompt; logger.info("Old prompt file", { - email: user.email, + email, exists: oldPromptFile ? "exists" : "does not exist", }); if (oldPromptFile === data.rulesPrompt) { - logger.info("No changes in rules prompt, returning early", { - email: user.email, - }); + logger.info("No changes in rules prompt, returning early", { email }); return { createdRules: 0, editedRules: 0, removedRules: 0 }; } @@ -345,17 +323,15 @@ export const saveRulesPromptAction = withActionInstrumentation( // check how the prompts have changed, and make changes to the rules accordingly if (oldPromptFile) { - logger.info("Comparing old and new prompts", { - email: user.email, - }); + logger.info("Comparing old and new prompts", { email }); const diff = await aiDiffRules({ - user: { ...user, email: user.email }, + user: emailAccount, oldPromptFile, newPromptFile: data.rulesPrompt, }); logger.info("Diff results", { - email: user.email, + email, addedRules: diff.addedRules.length, editedRules: diff.editedRules.length, removedRules: diff.removedRules.length, @@ -366,24 +342,20 @@ export const saveRulesPromptAction = withActionInstrumentation( !diff.editedRules.length && !diff.removedRules.length ) { - logger.info("No changes detected in rules, returning early", { - email: user.email, - }); + logger.info("No changes detected in rules, returning early", { email }); return { createdRules: 0, editedRules: 0, removedRules: 0 }; } if (diff.addedRules.length) { - logger.info("Processing added rules", { - email: user.email, - }); + logger.info("Processing added rules", { email }); addedRules = await aiPromptToRules({ - user: { ...user, email: user.email }, + user: emailAccount, promptFile: diff.addedRules.join("\n\n"), isEditing: false, - availableCategories: user.categories.map((c) => c.name), + availableCategories: emailAccount.user.categories.map((c) => c.name), }); logger.info("Added rules", { - email: user.email, + email, addedRules: addedRules?.length || 0, }); } @@ -394,12 +366,12 @@ export const saveRulesPromptAction = withActionInstrumentation( include: { actions: true }, }); logger.info("Found existing user rules", { - email: user.email, + email, count: userRules.length, }); const existingRules = await aiFindExistingRules({ - user: { ...user, email: user.email }, + user: emailAccount, promptRulesToEdit: diff.editedRules, promptRulesToRemove: diff.removedRules, databaseRules: userRules, @@ -407,14 +379,12 @@ export const saveRulesPromptAction = withActionInstrumentation( // remove rules logger.info("Processing rules for removal", { - email: user.email, + email, count: existingRules.removedRules.length, }); for (const rule of existingRules.removedRules) { if (!rule.rule) { - logger.error("Rule not found.", { - email: user.email, - }); + logger.error("Rule not found.", { email }); continue; } @@ -423,7 +393,7 @@ export const saveRulesPromptAction = withActionInstrumentation( }); logger.info("Removing rule", { - email: user.email, + email, promptRule: rule.promptRule, ruleName: rule.rule.name, ruleId: rule.rule.id, @@ -444,7 +414,7 @@ export const saveRulesPromptAction = withActionInstrumentation( } catch (error) { if (!isNotFoundError(error)) { logger.error("Error deleting rule", { - email: user.email, + email, ruleId: rule.rule.id, error: error instanceof Error ? error.message : "Unknown error", }); @@ -458,27 +428,27 @@ export const saveRulesPromptAction = withActionInstrumentation( // edit rules if (existingRules.editedRules.length > 0) { const editedRules = await aiPromptToRules({ - user: { ...user, email: user.email }, + user: emailAccount, promptFile: existingRules.editedRules .map( (r) => `Rule ID: ${r.rule?.id}. Prompt: ${r.updatedPromptRule}`, ) .join("\n\n"), isEditing: true, - availableCategories: user.categories.map((c) => c.name), + availableCategories: emailAccount.user.categories.map((c) => c.name), }); for (const rule of editedRules) { if (!rule.ruleId) { logger.error("Rule ID not found for rule", { - email: user.email, + email, promptRule: rule.name, }); continue; } logger.info("Editing rule", { - email: user.email, + email, promptRule: rule.name, ruleId: rule.ruleId, }); @@ -494,17 +464,15 @@ export const saveRulesPromptAction = withActionInstrumentation( } } } else { - logger.info("Processing new rules prompt with AI", { - email: user.email, - }); + logger.info("Processing new rules prompt with AI", { email }); addedRules = await aiPromptToRules({ - user: { ...user, email: user.email }, + user: emailAccount, promptFile: data.rulesPrompt, isEditing: false, - availableCategories: user.categories.map((c) => c.name), + availableCategories: emailAccount.user.categories.map((c) => c.name), }); logger.info("Rules to be added", { - email: user.email, + email, count: addedRules?.length || 0, }); } @@ -512,26 +480,26 @@ export const saveRulesPromptAction = withActionInstrumentation( // add new rules for (const rule of addedRules || []) { logger.info("Creating rule", { - email: user.email, + email, promptRule: rule.name, ruleId: rule.ruleId, }); - await safeCreateRule( - rule, - session.user.id, - rule.condition.categories?.categoryFilters || [], - ); + await safeCreateRule({ + result: rule, + userId: emailAccount.userId, + categoryNames: rule.condition.categories?.categoryFilters || [], + }); } // update rules prompt for user - await prisma.user.update({ - where: { id: session.user.id }, + await prisma.emailAccount.update({ + where: { email }, data: { rulesPrompt: data.rulesPrompt }, }); logger.info("Completed", { - email: user.email, + email, createdRules: addedRules?.length || 0, editedRules: editRulesCount, removedRules: removeRulesCount, @@ -557,12 +525,12 @@ export const generateRulesPromptAction = withActionInstrumentation( "generateRulesPrompt", async () => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; - const user = await getAiUser({ id: session.user.id }); + const user = await getAiUser({ email }); if (!user) return { error: "User not found" }; - if (!user.email) return { error: "User email not found" }; const gmail = getGmailClient(session); const lastSent = await getMessages(gmail, { @@ -642,7 +610,7 @@ export const reportAiMistakeAction = withActionInstrumentation( "reportAiMistake", async (unsafeBody: ReportAiMistakeBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; const { success, data, error } = reportAiMistakeBody.safeParse(unsafeBody); if (!success) return { error: error.message }; @@ -662,7 +630,7 @@ export const reportAiMistakeAction = withActionInstrumentation( where: { id: actualRuleId, userId: session.user.id }, }) : null, - getAiUser({ id: session.user.id }), + getAiUser({ email: session.user.email }), ]); if (expectedRuleId && !expectedRule) @@ -700,3 +668,25 @@ export const reportAiMistakeAction = withActionInstrumentation( }; }, ); + +async function getEmailAccountWithRules({ email }: { email: string }) { + return prisma.emailAccount.findUnique({ + where: { email }, + select: { + userId: true, + email: true, + about: true, + aiProvider: true, + aiModel: true, + aiApiKey: true, + user: { + select: { + rules: { + where: { enabled: true }, + include: { actions: true, categoryFilters: true }, + }, + }, + }, + }, + }); +} diff --git a/apps/web/utils/actions/assess.ts b/apps/web/utils/actions/assess.ts index 83d55cd47e..980aa37c5f 100644 --- a/apps/web/utils/actions/assess.ts +++ b/apps/web/utils/actions/assess.ts @@ -19,19 +19,20 @@ export const assessUserAction = withActionInstrumentation( "assessUser", async () => { const session = await auth(); - if (!session?.user.email) return { error: "Not authenticated" }; + const email = session?.user.email; + if (!email) return { error: "Not authenticated" }; const gmail = getGmailClient(session); - const assessedUser = await prisma.user.findUnique({ - where: { email: session.user.email }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { behaviorProfile: true }, }); - if (assessedUser?.behaviorProfile) return { success: true, skipped: true }; + if (emailAccount?.behaviorProfile) return { success: true, skipped: true }; const result = await assessUser({ gmail }); - await saveBehaviorProfile(session.user.email, result); + await saveBehaviorProfile(email, result); return { success: true }; }, @@ -41,7 +42,7 @@ async function saveBehaviorProfile( email: string, assessment: Awaited>, ) { - await prisma.user.update({ + await prisma.emailAccount.update({ where: { email }, data: { behaviorProfile: assessment }, }); @@ -51,14 +52,15 @@ export const analyzeWritingStyleAction = withActionInstrumentation( "analyzeWritingStyle", async () => { const session = await auth(); - if (!session?.user.email) return { error: "Not authenticated" }; + const email = session?.user.email; + if (!email) return { error: "Not authenticated" }; - const user = await getAiUser({ id: session.user.id }); + const user = await getAiUser({ email }); if (!user) return { error: "User not found" }; const emailAccount = await prisma.emailAccount.findUnique({ - where: { email: session.user.email }, - select: { id: true, writingStyle: true }, + where: { email }, + select: { writingStyle: true }, }); if (emailAccount?.writingStyle) return { success: true, skipped: true }; @@ -93,26 +95,26 @@ export const analyzeWritingStyleAction = withActionInstrumentation( if (emailAccount) { await prisma.emailAccount.update({ - where: { id: emailAccount.id }, + where: { email }, data: { writingStyle }, }); } else { const account = await prisma.account.findFirst({ - where: { userId: session.user.id }, + where: { email }, select: { id: true }, }); if (!account) { - logger.error("Account not found", { userId: session.user.id }); + logger.error("Account not found", { email }); return { error: "Account not found" }; } await prisma.emailAccount.create({ data: { - email: session.user.email, + email, + userId: session.user.id, accountId: account.id, writingStyle, - userId: session.user.id, }, }); } diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 876c381123..39bfc9fdc0 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -34,12 +34,12 @@ export const bulkCategorizeSendersAction = withActionInstrumentation( if (isActionError(sessionResult)) return sessionResult; const { user } = sessionResult; - const userResult = await validateUserAndAiAccess(user.id); + const userResult = await validateUserAndAiAccess({ email: user.email }); if (isActionError(userResult)) return userResult; // Delete empty queues as Qstash has a limit on how many queues we can have // We could run this in a cron too but simplest to do here for now - deleteEmptyCategorizeSendersQueues({ skipUserId: user.id }).catch( + deleteEmptyCategorizeSendersQueues({ skipEmail: user.email }).catch( (error) => { logger.error("Error deleting empty queues", { error }); }, @@ -87,13 +87,13 @@ export const bulkCategorizeSendersAction = withActionInstrumentation( totalUncategorizedSenders += newUncategorizedSenders.length; await saveCategorizationTotalItems({ - userId: user.id, + email: user.email, totalItems: totalUncategorizedSenders, }); // publish to qstash await publishToAiCategorizeSendersQueue({ - userId: user.id, + email: user.email, senders: uncategorizedSenders, }); @@ -118,13 +118,13 @@ export const categorizeSenderAction = withActionInstrumentation( if (!session.accessToken) return { error: "No access token" }; - const userResult = await validateUserAndAiAccess(u.id); + const userResult = await validateUserAndAiAccess({ email: u.email }); if (isActionError(userResult)) return userResult; - const { user } = userResult; + const { emailAccount } = userResult; const result = await categorizeSender( senderAddress, - user, + emailAccount, gmail, session.accessToken, ); @@ -244,10 +244,11 @@ export const setAutoCategorizeAction = withActionInstrumentation( "setAutoCategorize", async (autoCategorizeSenders: boolean) => { const session = await auth(); - if (!session?.user) return { error: "Not authenticated" }; + const email = session?.user.email; + if (!email) return { error: "Not authenticated" }; - await prisma.user.update({ - where: { id: session.user.id }, + await prisma.emailAccount.update({ + where: { email }, data: { autoCategorizeSenders }, }); diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index 9da767b81a..22e4e1a9c1 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -39,8 +39,8 @@ export const cleanInboxAction = withActionInstrumentation( "cleanInbox", async (unsafeData: CleanInboxBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = cleanInboxSchema.safeParse(unsafeData); if (!success) return { error: error.message }; @@ -67,7 +67,8 @@ export const cleanInboxAction = withActionInstrumentation( // create a cleanup job const job = await prisma.cleanupJob.create({ data: { - userId, + userId: session?.user.id, + email, action: data.action, instructions: data.instructions, daysOld: data.daysOld, @@ -137,7 +138,7 @@ export const cleanInboxAction = withActionInstrumentation( }); logger.info("Fetched threads", { - userId, + email, threadCount: threads.length, nextPageToken, }); @@ -149,7 +150,7 @@ export const cleanInboxAction = withActionInstrumentation( const url = `${env.WEBHOOK_URL || env.NEXT_PUBLIC_BASE_URL}/api/clean`; logger.info("Pushing to Qstash", { - userId, + email, threadCount: threads.length, nextPageToken, }); @@ -160,7 +161,7 @@ export const cleanInboxAction = withActionInstrumentation( return { url, body: { - userId, + email, threadId: thread.id, markedDoneLabelId, processedLabelId, @@ -174,7 +175,7 @@ export const cleanInboxAction = withActionInstrumentation( // api keys or a global queue // problem with a global queue is that if there's a backlog users will have to wait for others to finish first flowControl: { - key: `ai-clean-${userId}`, + key: `ai-clean-${email}`, parallelism: 3, }, }; @@ -205,8 +206,8 @@ export const undoCleanInboxAction = withActionInstrumentation( "undoCleanInbox", async (unsafeData: UndoCleanInboxBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = undoCleanInboxSchema.safeParse(unsafeData); if (!success) return { error: error.message }; @@ -243,14 +244,19 @@ export const undoCleanInboxAction = withActionInstrumentation( try { // We need to get the thread first to get the jobId const thread = await prisma.cleanupThread.findFirst({ - where: { userId, threadId }, + where: { userId: session?.user.id, threadId }, orderBy: { createdAt: "desc" }, }); if (thread) { - await updateThread(userId, thread.jobId, threadId, { - undone: true, - archive: false, // Reset the archive status since we've undone it + await updateThread({ + email, + jobId: thread.jobId, + threadId, + update: { + undone: true, + archive: false, // Reset the archive status since we've undone it + }, }); } } catch (error) { @@ -269,8 +275,8 @@ export const changeKeepToDoneAction = withActionInstrumentation( "changeKeepToDone", async (unsafeData: ChangeKeepToDoneBody) => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, success, error } = changeKeepToDoneSchema.safeParse(unsafeData); @@ -301,15 +307,26 @@ export const changeKeepToDoneAction = withActionInstrumentation( try { // We need to get the thread first to get the jobId const thread = await prisma.cleanupThread.findFirst({ - where: { userId, threadId }, + where: { userId: session?.user.id, threadId }, orderBy: { createdAt: "desc" }, }); if (thread) { - await updateThread(userId, thread.jobId, threadId, { - archive: action === CleanAction.ARCHIVE, - status: "completed", - undone: true, + // await updateThread(userId, thread.jobId, threadId, { + // archive: action === CleanAction.ARCHIVE, + // status: "completed", + // undone: true, + // }); + + await updateThread({ + email, + jobId: thread.jobId, + threadId, + update: { + archive: action === CleanAction.ARCHIVE, + status: "completed", + undone: true, + }, }); } } catch (error) { diff --git a/apps/web/utils/actions/cold-email.ts b/apps/web/utils/actions/cold-email.ts index 39f5b9d67a..0cecc8e8c5 100644 --- a/apps/web/utils/actions/cold-email.ts +++ b/apps/web/utils/actions/cold-email.ts @@ -28,13 +28,14 @@ export const updateColdEmailSettingsAction = withActionInstrumentation( "updateColdEmailSettings", async (body: UpdateColdEmailSettingsBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, error } = updateColdEmailSettingsBody.safeParse(body); if (error) return { error: error.message }; - await prisma.user.update({ - where: { id: session.user.id }, + await prisma.emailAccount.update({ + where: { email }, data: { coldEmailBlocker: data.coldEmailBlocker }, }); }, @@ -44,13 +45,14 @@ export const updateColdEmailPromptAction = withActionInstrumentation( "updateColdEmailPrompt", async (body: UpdateColdEmailPromptBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data, error } = updateColdEmailPromptBody.safeParse(body); if (error) return { error: error.message }; - await prisma.user.update({ - where: { id: session.user.id }, + await prisma.emailAccount.update({ + where: { email }, data: { coldEmailPrompt: data.coldEmailPrompt }, }); }, @@ -133,17 +135,18 @@ export const testColdEmailAction = withActionInstrumentation( async function checkColdEmail( body: ColdEmailBlockerBody, gmail: gmail_v1.Gmail, - userEmail: string, + email: string, ) { - const user = await prisma.user.findUniqueOrThrow({ - where: { email: userEmail }, + const emailAccount = await prisma.emailAccount.findUniqueOrThrow({ + where: { email }, select: { - id: true, + userId: true, email: true, coldEmailPrompt: true, aiProvider: true, aiModel: true, aiApiKey: true, + about: true, }, }); @@ -162,7 +165,7 @@ async function checkColdEmail( threadId: body.threadId || undefined, id: body.messageId || "", }, - user, + user: emailAccount, gmail, }); diff --git a/apps/web/utils/actions/generate-reply.ts b/apps/web/utils/actions/generate-reply.ts index f5a21fdf38..947d758267 100644 --- a/apps/web/utils/actions/generate-reply.ts +++ b/apps/web/utils/actions/generate-reply.ts @@ -15,9 +15,10 @@ export const generateNudgeReplyAction = withActionInstrumentation( "generateNudgeReply", async (unsafeData: GenerateReplySchema) => { const session = await auth(); - if (!session?.user.email) return { error: "Not authenticated" }; + const email = session?.user.email; + if (!email) return { error: "Not authenticated" }; - const user = await getAiUser({ id: session.user.id }); + const user = await getAiUser({ email }); if (!user) return { error: "User not found" }; @@ -29,7 +30,7 @@ export const generateNudgeReplyAction = withActionInstrumentation( if (!lastMessage) return { error: "No message provided" }; const reply = await getReply({ - userId: user.id, + email, messageId: lastMessage.id, }); @@ -47,7 +48,7 @@ export const generateNudgeReplyAction = withActionInstrumentation( const text = await aiGenerateNudge({ messages, user }); await saveReply({ - userId: user.id, + email, messageId: lastMessage.id, reply: text, }); diff --git a/apps/web/utils/actions/reply-tracking.ts b/apps/web/utils/actions/reply-tracking.ts index 4925cc7e97..98a0db385e 100644 --- a/apps/web/utils/actions/reply-tracking.ts +++ b/apps/web/utils/actions/reply-tracking.ts @@ -21,10 +21,10 @@ export const enableReplyTrackerAction = withActionInstrumentation( "enableReplyTracker", async () => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; - await enableReplyTracker(userId); + await enableReplyTracker({ email }); revalidatePath("/reply-zero"); @@ -36,10 +36,10 @@ export const processPreviousSentEmailsAction = withActionInstrumentation( "processPreviousSentEmails", async () => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; - const user = await getAiUser({ id: userId }); + const user = await getAiUser({ email }); if (!user) return { error: "User not found" }; const gmail = getGmailClient({ accessToken: session.accessToken }); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 798298bbe8..b685233f3b 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -55,7 +55,8 @@ export const createRuleAction = withActionInstrumentation( "createRule", async (options: CreateRuleBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data: body, error } = createRuleBody.safeParse(options); if (error) return { error: error.message }; @@ -108,7 +109,7 @@ export const createRuleAction = withActionInstrumentation( include: { actions: true, categoryFilters: true, group: true }, }); - await updatePromptFileOnRuleCreated(session.user.id, rule); + await updatePromptFileOnRuleCreated({ email, rule }); revalidatePath("/automation"); @@ -133,7 +134,8 @@ export const updateRuleAction = withActionInstrumentation( "updateRule", async (options: UpdateRuleBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data: body, error } = updateRuleBody.safeParse(options); if (error) return { error: error.message }; @@ -231,11 +233,11 @@ export const updateRuleAction = withActionInstrumentation( ]); // update prompt file - await updatePromptFileOnRuleUpdated( - session.user.id, + await updatePromptFileOnRuleUpdated({ + email, currentRule, updatedRule, - ); + }); revalidatePath(`/automation/rule/${body.id}`); revalidatePath("/automation"); @@ -261,7 +263,8 @@ export const updateRuleInstructionsAction = withActionInstrumentation( "updateRuleInstructions", async (options: UpdateRuleInstructionsBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { data: body, error } = updateRuleInstructionsBody.safeParse(options); if (error) return { error: error.message }; @@ -273,6 +276,7 @@ export const updateRuleInstructionsAction = withActionInstrumentation( if (!currentRule) return { error: "Rule not found" }; await updateRuleInstructionsAndPromptFile({ + email, userId: session.user.id, ruleId: body.id, instructions: body.instructions, @@ -345,7 +349,8 @@ export const deleteRuleAction = withActionInstrumentation( "deleteRule", async ({ ruleId }: { ruleId: string }) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const rule = await prisma.rule.findUnique({ where: { id: ruleId }, @@ -362,28 +367,30 @@ export const deleteRuleAction = withActionInstrumentation( groupId: rule.groupId, }); - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { + userId: true, email: true, + about: true, aiModel: true, aiProvider: true, aiApiKey: true, rulesPrompt: true, }, }); - if (!user) return { error: "User not found" }; + if (!emailAccount) return { error: "User not found" }; - if (!user.rulesPrompt) return; + if (!emailAccount.rulesPrompt) return; const updatedPrompt = await generatePromptOnDeleteRule({ - user, - existingPrompt: user.rulesPrompt, + user: emailAccount, + existingPrompt: emailAccount.rulesPrompt, deletedRule: rule, }); - await prisma.user.update({ - where: { id: session.user.id }, + await prisma.emailAccount.update({ + where: { email }, data: { rulesPrompt: updatedPrompt }, }); @@ -400,14 +407,14 @@ export const getRuleExamplesAction = withActionInstrumentation( "getRuleExamples", async (unsafeData: RulesExamplesBody) => { const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!session?.user.email) return { error: "Not logged in" }; const { success, error, data } = rulesExamplesBody.safeParse(unsafeData); if (!success) return { error: error.message }; const gmail = getGmailClient(session); - const user = await getAiUser({ id: session.user.id }); + const user = await getAiUser({ email: session.user.email }); if (!user) return { error: "User not found" }; const { matches } = await aiFindExampleMatches( @@ -424,14 +431,15 @@ export const createRulesOnboardingAction = withActionInstrumentation( "createRulesOnboarding", async (options: CreateRulesOnboardingBody) => { const session = await auth(); + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; const { data, error } = createRulesOnboardingBody.safeParse(options); if (error) return { error: error.message }; - const user = await prisma.user.findUnique({ - where: { id: userId }, + const user = await prisma.emailAccount.findUnique({ + where: { email }, select: { rulesPrompt: true }, }); if (!user) return { error: "User not found" }; @@ -443,8 +451,8 @@ export const createRulesOnboardingAction = withActionInstrumentation( // cold email blocker if (isSet(data.coldEmail)) { - const promise = prisma.user.update({ - where: { id: userId }, + const promise = prisma.emailAccount.update({ + where: { email }, data: { coldEmailBlocker: data.coldEmail === "label" @@ -459,7 +467,7 @@ export const createRulesOnboardingAction = withActionInstrumentation( // reply tracker if (isSet(data.toReply)) { - const promise = enableReplyTracker(session.user.id).then((res) => { + const promise = enableReplyTracker({ email }).then((res) => { if (res?.alreadyEnabled) return; // Load previous emails needing replies in background @@ -472,9 +480,7 @@ export const createRulesOnboardingAction = withActionInstrumentation( "Content-Type": "application/json", [INTERNAL_API_KEY_HEADER]: env.INTERNAL_API_KEY, }, - body: JSON.stringify({ - userId: session.user.id, - } satisfies ProcessPreviousBody), + body: JSON.stringify({ email } satisfies ProcessPreviousBody), }, ); }); @@ -655,8 +661,8 @@ export const createRulesOnboardingAction = withActionInstrumentation( await Promise.allSettled(promises); - await prisma.user.update({ - where: { id: session.user.id }, + await prisma.emailAccount.update({ + where: { email }, data: { rulesPrompt: `${user.rulesPrompt || ""}\n${rules.map((r) => `* ${r}`).join("\n")}`.trim(), diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index ccecfe7e95..b7a29ba197 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -19,13 +19,14 @@ export const saveAboutAction = withActionInstrumentation( "saveAbout", async (unsafeBody: SaveAboutBody) => { const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { success, data, error } = saveAboutBody.safeParse(unsafeBody); if (!success) return { error: error.message }; - await prisma.user.update({ - where: { email: session.user.email }, + await prisma.emailAccount.update({ + where: { email }, data: { about: data.about }, }); @@ -40,13 +41,14 @@ export const saveSignatureAction = withActionInstrumentation( "saveSignature", async (unsafeBody: SaveSignatureBody) => { const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const { success, data, error } = saveSignatureBody.safeParse(unsafeBody); if (!success) return { error: error.message }; - await prisma.user.update({ - where: { email: session.user.email }, + await prisma.emailAccount.update({ + where: { email }, data: { signature: data.signature }, }); }, diff --git a/apps/web/utils/actions/webhook.ts b/apps/web/utils/actions/webhook.ts index db48e61ae2..f2b81a4b7f 100644 --- a/apps/web/utils/actions/webhook.ts +++ b/apps/web/utils/actions/webhook.ts @@ -9,13 +9,13 @@ export const regenerateWebhookSecretAction = withActionInstrumentation( "regenerateWebhookSecret", async () => { const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + const email = session?.user.email; + if (!email) return { error: "Not logged in" }; const webhookSecret = generateWebhookSecret(); - await prisma.user.update({ - where: { id: userId }, + await prisma.emailAccount.update({ + where: { email }, data: { webhookSecret }, }); diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 58a0dcb9e5..e1828487b5 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -47,7 +47,7 @@ export async function processUserRequest({ categories, senderCategory, }: { - user: Pick & UserEmailWithAI; + user: UserEmailWithAI; rules: RuleWithRelations[]; originalEmail: ParsedMessage | null; messages: { role: "assistant" | "user"; content: string }[]; @@ -160,7 +160,7 @@ ${senderCategory || "No category"} const updatedRules = new Map(); const loggerOptions = { - userId: user.id, + userId: user.userId, email: user.email, messageId: originalEmail?.id, threadId: originalEmail?.threadId, @@ -352,7 +352,10 @@ ${senderCategory || "No category"} } try { - await deleteGroupItem({ id: groupItem.id, userId: user.id }); + await deleteGroupItem({ + id: groupItem.id, + userId: user.userId, + }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -379,7 +382,7 @@ ${senderCategory || "No category"} ...(categories ? { update_sender_category: getUpdateCategoryTool( - user.id, + user.userId, categories, loggerOptions, user.email, @@ -518,13 +521,13 @@ ${senderCategory || "No category"} try { const categoryIds = await getUserCategoriesForNames( - user.id, + user.userId, conditions.categories?.categoryFilters || [], ); const rule = await createRule({ result: { name, condition, actions }, - userId: user.id, + userId: user.userId, categoryIds, }); @@ -588,12 +591,17 @@ ${senderCategory || "No category"} // Update prompt file for newly created rules for (const rule of createdRules.values()) { - await updatePromptFileOnRuleCreated(user.id, rule); + await updatePromptFileOnRuleCreated({ email: user.email, rule }); } // Update prompt file for modified rules for (const rule of updatedRules.values()) { - await updatePromptFileOnRuleUpdated(user.id, rule, rule); + // TODO: should current and updated be the same? + await updatePromptFileOnRuleUpdated({ + email: user.email, + currentRule: rule, + updatedRule: rule, + }); } posthogCaptureEvent(user.email, "AI Assistant Process Completed", { diff --git a/apps/web/utils/ai/choose-rule/match-rules.ts b/apps/web/utils/ai/choose-rule/match-rules.ts index 004ebe9f01..5ad294de7c 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.ts @@ -12,7 +12,6 @@ import type { import { CategoryFilterType, LogicalOperator, - type User, SystemType, } from "@prisma/client"; import { ConditionType } from "@/utils/config"; @@ -20,7 +19,7 @@ import prisma from "@/utils/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { getEmailForLLM } from "@/utils/get-email-from-message"; import { isReplyInThread } from "@/utils/thread"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { MatchReason, @@ -207,7 +206,7 @@ function getMatchReason(matchReasons?: MatchReason[]): string | undefined { export async function findMatchingRule( rules: RuleWithActionsAndCategories[], message: ParsedMessage, - user: Pick & UserAIFields, + user: UserEmailWithAI, gmail: gmail_v1.Gmail, ) { const result = await findMatchingRuleWithReasons(rules, message, user, gmail); @@ -220,7 +219,7 @@ export async function findMatchingRule( async function findMatchingRuleWithReasons( rules: RuleWithActionsAndCategories[], message: ParsedMessage, - user: Pick & UserAIFields, + user: UserEmailWithAI, gmail: gmail_v1.Gmail, ): Promise<{ rule?: RuleWithActionsAndCategories; diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index d5035c7d03..93ef3c0d0a 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -4,7 +4,7 @@ import type { ParsedMessage, RuleWithActionsAndCategories, } from "@/utils/types"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { ExecutedRuleStatus, Prisma, @@ -42,12 +42,12 @@ export async function runRules({ gmail: gmail_v1.Gmail; message: ParsedMessage; rules: RuleWithActionsAndCategories[]; - user: Pick & UserAIFields; + user: UserEmailWithAI; isTest: boolean; }): Promise { const result = await findMatchingRule(rules, message, user, gmail); - analyzeSenderPatternIfAiMatch(isTest, result, message, user); + analyzeSenderPatternIfAiMatch(isTest, result, message, user.userId); logger.trace("Matching rule", { result }); @@ -63,7 +63,7 @@ export async function runRules({ ); } else { await saveSkippedExecutedRule({ - userId: user.id, + userId: user.userId, threadId: message.threadId, messageId: message.id, reason: result.reason, @@ -75,7 +75,7 @@ export async function runRules({ async function executeMatchedRule( rule: RuleWithActionsAndCategories, message: ParsedMessage, - user: Pick & UserAIFields, + user: UserEmailWithAI, gmail: gmail_v1.Gmail, reason: string | undefined, matchReasons: MatchReason[] | undefined, @@ -94,7 +94,7 @@ async function executeMatchedRule( ? undefined : await saveExecutedRule( { - userId: user.id, + userId: user.userId, threadId: message.threadId, messageId: message.id, }, @@ -247,7 +247,7 @@ async function analyzeSenderPatternIfAiMatch( isTest: boolean, result: { rule?: Rule | null; matchReasons?: MatchReason[] }, message: ParsedMessage, - user: Pick, + userId: string, ) { if ( !isTest && @@ -265,7 +265,7 @@ async function analyzeSenderPatternIfAiMatch( if (fromAddress) { after(() => analyzeSenderPattern({ - userId: user.id, + userId, from: fromAddress, }), ); diff --git a/apps/web/utils/ai/example-matches/find-example-matches.ts b/apps/web/utils/ai/example-matches/find-example-matches.ts index b51e71c138..a805cefb1e 100644 --- a/apps/web/utils/ai/example-matches/find-example-matches.ts +++ b/apps/web/utils/ai/example-matches/find-example-matches.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionTools } from "@/utils/llms"; import type { User } from "@prisma/client"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { queryBatchMessages } from "@/utils/gmail/message"; const FIND_EXAMPLE_MATCHES = "findExampleMatches"; @@ -27,7 +27,7 @@ export const findExampleMatchesSchema = z.object({ }); export async function aiFindExampleMatches( - user: Pick & UserAIFields, + user: UserEmailWithAI, gmail: gmail_v1.Gmail, rulesPrompt: string, ) { diff --git a/apps/web/utils/ai/group/create-group.ts b/apps/web/utils/ai/group/create-group.ts index 0d3325007c..8779f0fec6 100644 --- a/apps/web/utils/ai/group/create-group.ts +++ b/apps/web/utils/ai/group/create-group.ts @@ -3,7 +3,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionTools } from "@/utils/llms"; import type { Group, User } from "@prisma/client"; import { queryBatchMessages } from "@/utils/gmail/message"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; // no longer in use. delete? @@ -54,7 +54,7 @@ const listEmailsTool = (gmail: gmail_v1.Gmail) => ({ }); export async function aiGenerateGroupItems( - user: Pick & UserAIFields, + user: UserEmailWithAI, gmail: gmail_v1.Gmail, group: Pick, ): Promise> { @@ -127,7 +127,7 @@ Key guidelines: } async function verifyGroupItems( - user: Pick & UserAIFields, + user: UserEmailWithAI, gmail: gmail_v1.Gmail, group: Pick, initialItems: z.infer, diff --git a/apps/web/utils/ai/rule/create-rule.ts b/apps/web/utils/ai/rule/create-rule.ts index 8e4b240792..f792b8ae85 100644 --- a/apps/web/utils/ai/rule/create-rule.ts +++ b/apps/web/utils/ai/rule/create-rule.ts @@ -1,12 +1,11 @@ import type { z } from "zod"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { chatCompletionTools } from "@/utils/llms"; import { createRuleSchema } from "@/utils/ai/rule/create-rule-schema"; export async function aiCreateRule( instructions: string, - user: UserAIFields, - userEmail: string, + user: UserEmailWithAI, ) { const system = "You are an AI assistant that helps people manage their emails."; @@ -22,7 +21,7 @@ export async function aiCreateRule( parameters: createRuleSchema, }, }, - userEmail, + userEmail: user.email, label: "Categorize rule", }); diff --git a/apps/web/utils/ai/rule/diff-rules.ts b/apps/web/utils/ai/rule/diff-rules.ts index af324e30b3..896be6a9a9 100644 --- a/apps/web/utils/ai/rule/diff-rules.ts +++ b/apps/web/utils/ai/rule/diff-rules.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createPatch } from "diff"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; const parameters = z.object({ addedRules: z.array(z.string()).describe("The added rules"), @@ -21,7 +21,7 @@ export async function aiDiffRules({ oldPromptFile, newPromptFile, }: { - user: UserAIFields & { email: string }; + user: UserEmailWithAI; oldPromptFile: string; newPromptFile: string; }) { diff --git a/apps/web/utils/ai/rule/find-existing-rules.ts b/apps/web/utils/ai/rule/find-existing-rules.ts index adb246d3c3..9238db77bd 100644 --- a/apps/web/utils/ai/rule/find-existing-rules.ts +++ b/apps/web/utils/ai/rule/find-existing-rules.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import type { Action, Rule } from "@prisma/client"; const parameters = z.object({ @@ -22,7 +22,7 @@ export async function aiFindExistingRules({ promptRulesToRemove, databaseRules, }: { - user: UserAIFields & { email: string }; + user: UserEmailWithAI; promptRulesToEdit: { oldRule: string; newRule: string }[]; promptRulesToRemove: string[]; databaseRules: (Rule & { actions: Action[] })[]; diff --git a/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts b/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts index fe9ca6d44b..d69e54e3e7 100644 --- a/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts +++ b/apps/web/utils/ai/rule/generate-prompt-on-delete-rule.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; -import type { User } from "@prisma/client"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { RuleWithRelations } from "./create-prompt-from-rule"; import { createPromptFromRule } from "./create-prompt-from-rule"; @@ -19,7 +18,7 @@ export async function generatePromptOnDeleteRule({ existingPrompt, deletedRule, }: { - user: UserAIFields & Pick; + user: UserEmailWithAI; existingPrompt: string; deletedRule: RuleWithRelations; }): Promise { diff --git a/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts b/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts index 939f990b58..aff0ad6a97 100644 --- a/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts +++ b/apps/web/utils/ai/rule/generate-prompt-on-update-rule.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; -import type { User } from "@prisma/client"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; import type { RuleWithRelations } from "./create-prompt-from-rule"; import { createPromptFromRule } from "./create-prompt-from-rule"; @@ -20,7 +19,7 @@ export async function generatePromptOnUpdateRule({ currentRule, updatedRule, }: { - user: UserAIFields & Pick; + user: UserEmailWithAI; existingPrompt: string; currentRule: RuleWithRelations; updatedRule: RuleWithRelations; diff --git a/apps/web/utils/ai/rule/generate-rules-prompt.ts b/apps/web/utils/ai/rule/generate-rules-prompt.ts index 66d93760f7..adfd991de5 100644 --- a/apps/web/utils/ai/rule/generate-rules-prompt.ts +++ b/apps/web/utils/ai/rule/generate-rules-prompt.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; -import type { User } from "@prisma/client"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("ai-generate-rules-prompt"); @@ -34,7 +33,7 @@ export async function aiGenerateRulesPrompt({ snippets, userLabels, }: { - user: UserAIFields & Pick; + user: UserEmailWithAI; lastSentEmails: string[]; userLabels: string[]; snippets: string[]; diff --git a/apps/web/utils/ai/rule/prompt-to-rules.ts b/apps/web/utils/ai/rule/prompt-to-rules.ts index 6fb18ff794..d85dbcc43b 100644 --- a/apps/web/utils/ai/rule/prompt-to-rules.ts +++ b/apps/web/utils/ai/rule/prompt-to-rules.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { zodToJsonSchema } from "zod-to-json-schema"; import { chatCompletionTools } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserAIFields, UserEmailWithAI } from "@/utils/llms/types"; import { createRuleSchema, getCreateRuleSchemaWithCategories, @@ -22,7 +22,7 @@ export async function aiPromptToRules({ isEditing, availableCategories, }: { - user: UserAIFields & { email: string }; + user: UserEmailWithAI; promptFile: string; isEditing: boolean; availableCategories?: string[]; diff --git a/apps/web/utils/categorize/senders/categorize.ts b/apps/web/utils/categorize/senders/categorize.ts index a052739279..d602941054 100644 --- a/apps/web/utils/categorize/senders/categorize.ts +++ b/apps/web/utils/categorize/senders/categorize.ts @@ -21,7 +21,7 @@ export async function categorizeSender( accessToken: string, userCategories?: Pick[], ) { - const categories = userCategories || (await getUserCategories(user.id)); + const categories = userCategories || (await getUserCategories(user.userId)); if (categories.length === 0) return { categoryId: undefined }; const previousEmails = await getThreadsFromSenderWithSubject( @@ -43,7 +43,7 @@ export async function categorizeSender( sender: senderAddress, categories, categoryName: aiResult.category, - userId: user.id, + userId: user.userId, }); return { categoryId: newsletter.categoryId }; diff --git a/apps/web/utils/cold-email/is-cold-email.ts b/apps/web/utils/cold-email/is-cold-email.ts index a05d67a87e..864ee6c198 100644 --- a/apps/web/utils/cold-email/is-cold-email.ts +++ b/apps/web/utils/cold-email/is-cold-email.ts @@ -1,10 +1,14 @@ import { z } from "zod"; import type { gmail_v1 } from "@googleapis/gmail"; import { chatCompletionObject } from "@/utils/llms"; -import type { UserAIFields } from "@/utils/llms/types"; +import type { UserEmailWithAI } from "@/utils/llms/types"; import { getOrCreateInboxZeroLabel, GmailLabel } from "@/utils/gmail/label"; import { labelMessage } from "@/utils/gmail/label"; -import { ColdEmailSetting, ColdEmailStatus, type User } from "@prisma/client"; +import { + ColdEmailSetting, + ColdEmailStatus, + type EmailAccount, +} from "@prisma/client"; import prisma from "@/utils/prisma"; import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt"; import { stringifyEmail } from "@/utils/stringify-email"; @@ -22,7 +26,7 @@ export async function isColdEmail({ gmail, }: { email: EmailForLLM & { threadId?: string }; - user: Pick & UserAIFields; + user: Pick & UserEmailWithAI; gmail: gmail_v1.Gmail; }): Promise<{ isColdEmail: boolean; @@ -30,7 +34,7 @@ export async function isColdEmail({ aiReason?: string | null; }> { const loggerOptions = { - userId: user.id, + userId: user.userId, email: user.email, threadId: email.threadId, messageId: email.id, @@ -41,7 +45,7 @@ export async function isColdEmail({ // Check if we marked it as a cold email already const isColdEmailer = await isKnownColdEmailSender({ from: email.from, - userId: user.id, + userId: user.userId, }); if (isColdEmailer) { @@ -100,7 +104,7 @@ async function isKnownColdEmailSender({ async function aiIsColdEmail( email: EmailForLLM, - user: Pick & UserAIFields, + user: Pick & UserEmailWithAI, ) { const system = `You are an assistant that decides if an email is a cold email or not. @@ -149,8 +153,8 @@ ${stringifyEmail(email, 500)} export async function runColdEmailBlocker(options: { email: EmailForLLM & { threadId: string }; gmail: gmail_v1.Gmail; - user: Pick & - UserAIFields; + user: Pick & + UserEmailWithAI; }) { const response = await isColdEmail(options); if (response.isColdEmail) @@ -161,18 +165,18 @@ export async function runColdEmailBlocker(options: { export async function blockColdEmail(options: { gmail: gmail_v1.Gmail; email: { from: string; id: string; threadId: string }; - user: Pick; + user: Pick & UserEmailWithAI; aiReason: string | null; }) { const { gmail, email, user, aiReason } = options; await prisma.coldEmail.upsert({ - where: { userId_fromEmail: { userId: user.id, fromEmail: email.from } }, + where: { userId_fromEmail: { userId: user.userId, fromEmail: email.from } }, update: { status: ColdEmailStatus.AI_LABELED_COLD }, create: { status: ColdEmailStatus.AI_LABELED_COLD, fromEmail: email.from, - userId: user.id, + userId: user.userId, reason: aiReason, messageId: email.id, threadId: email.threadId, @@ -190,7 +194,7 @@ export async function blockColdEmail(options: { key: "cold_email", }); if (!coldEmailLabel?.id) - logger.error("No gmail label id", { userId: user.id }); + logger.error("No gmail label id", { userId: user.userId }); const shouldArchive = user.coldEmailBlocker === ColdEmailSetting.ARCHIVE_AND_LABEL || diff --git a/apps/web/utils/llms/types.ts b/apps/web/utils/llms/types.ts index 8571bb4f9e..8b83da0038 100644 --- a/apps/web/utils/llms/types.ts +++ b/apps/web/utils/llms/types.ts @@ -1,5 +1,8 @@ -import type { User } from "@prisma/client"; +import type { EmailAccount } from "@prisma/client"; -export type UserAIFields = Pick; -export type UserEmailWithAI = Pick & +export type UserAIFields = Pick< + EmailAccount, + "aiProvider" | "aiModel" | "aiApiKey" +>; +export type UserEmailWithAI = Pick & UserAIFields; diff --git a/apps/web/utils/redis/categorization-progress.ts b/apps/web/utils/redis/categorization-progress.ts index bf121fe4af..f728bfadfb 100644 --- a/apps/web/utils/redis/categorization-progress.ts +++ b/apps/web/utils/redis/categorization-progress.ts @@ -7,30 +7,30 @@ const categorizationProgressSchema = z.object({ }); type RedisCategorizationProgress = z.infer; -function getKey(userId: string) { - return `categorization-progress:${userId}`; +function getKey({ email }: { email: string }) { + return `categorization-progress:${email}`; } export async function getCategorizationProgress({ - userId, + email, }: { - userId: string; + email: string; }) { - const key = getKey(userId); + const key = getKey({ email }); const progress = await redis.get(key); if (!progress) return null; return progress; } export async function saveCategorizationTotalItems({ - userId, + email, totalItems, }: { - userId: string; + email: string; totalItems: number; }) { - const key = getKey(userId); - const existingProgress = await getCategorizationProgress({ userId }); + const key = getKey({ email }); + const existingProgress = await getCategorizationProgress({ email }); await redis.set( key, { @@ -42,16 +42,16 @@ export async function saveCategorizationTotalItems({ } export async function saveCategorizationProgress({ - userId, + email, incrementCompleted, }: { - userId: string; + email: string; incrementCompleted: number; }) { - const existingProgress = await getCategorizationProgress({ userId }); + const existingProgress = await getCategorizationProgress({ email }); if (!existingProgress) return null; - const key = getKey(userId); + const key = getKey({ email }); const updatedProgress: RedisCategorizationProgress = { ...existingProgress, completedItems: (existingProgress.completedItems || 0) + incrementCompleted, @@ -61,12 +61,3 @@ export async function saveCategorizationProgress({ await redis.set(key, updatedProgress, { ex: 2 * 60 }); return updatedProgress; } - -export async function deleteCategorizationProgress({ - userId, -}: { - userId: string; -}) { - const key = getKey(userId); - await redis.del(key); -} diff --git a/apps/web/utils/redis/clean.ts b/apps/web/utils/redis/clean.ts index 95c2c62ec3..7dc4f7a86f 100644 --- a/apps/web/utils/redis/clean.ts +++ b/apps/web/utils/redis/clean.ts @@ -4,11 +4,11 @@ import { isDefined } from "@/utils/types"; const EXPIRATION = 60 * 60 * 6; // 6 hours -const threadKey = (userId: string, jobId: string, threadId: string) => - `thread:${userId}:${jobId}:${threadId}`; +const threadKey = (email: string, jobId: string, threadId: string) => + `thread:${email}:${jobId}:${threadId}`; export async function saveThread( - userId: string, + email: string, thread: { threadId: string; jobId: string; @@ -22,33 +22,44 @@ export async function saveThread( ): Promise { const cleanThread: CleanThread = { ...thread, - userId, + email, status: "processing", createdAt: new Date().toISOString(), }; - await publishThread(userId, cleanThread); + await publishThread({ email, thread: cleanThread }); return cleanThread; } -export async function updateThread( - userId: string, - jobId: string, - threadId: string, - update: Partial, -) { - const thread = await getThread(userId, jobId, threadId); +export async function updateThread({ + email, + jobId, + threadId, + update, +}: { + email: string; + jobId: string; + threadId: string; + update: Partial; +}) { + const thread = await getThread(email, jobId, threadId); if (!thread) { console.warn("thread not found:", threadId); return; } const updatedThread = { ...thread, ...update }; - await publishThread(userId, updatedThread); + await publishThread({ email, thread: updatedThread }); } -export async function publishThread(userId: string, thread: CleanThread) { - const key = threadKey(userId, thread.jobId, thread.threadId); +export async function publishThread({ + email, + thread, +}: { + email: string; + thread: CleanThread; +}) { + const key = threadKey(email, thread.jobId, thread.threadId); // Store the data with expiration await redis.set(key, thread, { ex: EXPIRATION }); @@ -56,8 +67,8 @@ export async function publishThread(userId: string, thread: CleanThread) { await redis.publish(key, JSON.stringify(thread)); } -async function getThread(userId: string, jobId: string, threadId: string) { - const key = threadKey(userId, jobId, threadId); +async function getThread(email: string, jobId: string, threadId: string) { + const key = threadKey(email, jobId, threadId); return redis.get(key); } diff --git a/apps/web/utils/redis/clean.types.ts b/apps/web/utils/redis/clean.types.ts index 38905b87fc..53fe9bc955 100644 --- a/apps/web/utils/redis/clean.types.ts +++ b/apps/web/utils/redis/clean.types.ts @@ -1,6 +1,6 @@ export type CleanThread = { threadId: string; - userId: string; + email: string; jobId: string; status: "processing" | "applying" | "completed"; createdAt: string; diff --git a/apps/web/utils/redis/reply.ts b/apps/web/utils/redis/reply.ts index 2894b0f166..380826245d 100644 --- a/apps/web/utils/redis/reply.ts +++ b/apps/web/utils/redis/reply.ts @@ -1,35 +1,35 @@ import { redis } from "@/utils/redis"; function getReplyKey({ - userId, + email, messageId, }: { - userId: string; + email: string; messageId: string; }) { - return `reply:${userId}:${messageId}`; + return `reply:${email}:${messageId}`; } export async function getReply({ - userId, + email, messageId, }: { - userId: string; + email: string; messageId: string; }): Promise { - return redis.get(getReplyKey({ userId, messageId })); + return redis.get(getReplyKey({ email, messageId })); } export async function saveReply({ - userId, + email, messageId, reply, }: { - userId: string; + email: string; messageId: string; reply: string; }) { - return redis.set(getReplyKey({ userId, messageId }), reply, { + return redis.set(getReplyKey({ email, messageId }), reply, { ex: 60 * 60 * 24, // 1 day }); } diff --git a/apps/web/utils/reply-tracker/enable.ts b/apps/web/utils/reply-tracker/enable.ts index 8ac97e1f34..3a41de8482 100644 --- a/apps/web/utils/reply-tracker/enable.ts +++ b/apps/web/utils/reply-tracker/enable.ts @@ -8,39 +8,36 @@ import { import { createScopedLogger } from "@/utils/logger"; import { RuleName } from "@/utils/rule/consts"; -export async function enableReplyTracker(userId: string) { - const logger = createScopedLogger("reply-tracker/enable").with({ userId }); - - // If enabled already skip - const existingRuleAction = await prisma.rule.findFirst({ - where: { userId, actions: { some: { type: ActionType.TRACK_THREAD } } }, - }); - - if (existingRuleAction) return { success: true, alreadyEnabled: true }; +export async function enableReplyTracker({ email }: { email: string }) { + const logger = createScopedLogger("reply-tracker/enable").with({ email }); // Find existing reply required rule, make it track replies - const user = await prisma.user.findUnique({ - where: { id: userId }, + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { - id: true, + userId: true, email: true, aiProvider: true, aiModel: true, aiApiKey: true, about: true, rulesPrompt: true, - rules: { - where: { - systemType: SystemType.TO_REPLY, - }, + user: { select: { - id: true, - systemType: true, - actions: { + rules: { + where: { + systemType: SystemType.TO_REPLY, + }, select: { id: true, - type: true, - label: true, + systemType: true, + actions: { + select: { + id: true, + type: true, + label: true, + }, + }, }, }, }, @@ -48,9 +45,16 @@ export async function enableReplyTracker(userId: string) { }, }); - if (!user) return { error: "User not found" }; + // If enabled already skip + if (!emailAccount) return { error: "Email account not found" }; - const rule = user.rules.find((r) => r.systemType === SystemType.TO_REPLY); + const rule = emailAccount.user.rules.find( + (r) => r.systemType === SystemType.TO_REPLY, + ); + + if (rule?.actions.find((a) => a.type === ActionType.TRACK_THREAD)) { + return { success: true, alreadyEnabled: true }; + } let ruleId: string | null = rule?.id || null; @@ -76,8 +80,8 @@ export async function enableReplyTracker(userId: string) { // If not found, create a reply required rule if (!ruleId) { - const newRule = await safeCreateRule( - { + const newRule = await safeCreateRule({ + result: { name: RuleName.ToReply, condition: { aiInstructions: defaultReplyTrackerInstructions, @@ -97,10 +101,9 @@ export async function enableReplyTracker(userId: string) { }, ], }, - userId, - null, - SystemType.TO_REPLY, - ); + userId: emailAccount.userId, + systemType: SystemType.TO_REPLY, + }); if ("error" in newRule) { logger.error("Error enabling Reply Zero", { error: newRule.error }); @@ -115,11 +118,11 @@ export async function enableReplyTracker(userId: string) { } // Add rule to prompt file - await prisma.user.update({ - where: { id: userId }, + await prisma.emailAccount.update({ + where: { email }, data: { rulesPrompt: - `${user.rulesPrompt || ""}\n\n* Label emails that require a reply as 'Reply Required'`.trim(), + `${emailAccount.rulesPrompt || ""}\n\n* Label emails that require a reply as 'Reply Required'`.trim(), }, }); } @@ -139,7 +142,7 @@ export async function enableReplyTracker(userId: string) { await Promise.allSettled([ enableReplyTracking(updatedRule), enableDraftReplies(updatedRule), - enableOutboundReplyTracking(userId), + enableOutboundReplyTracking({ email }), ]); } @@ -172,9 +175,9 @@ export async function enableDraftReplies( }); } -async function enableOutboundReplyTracking(userId: string) { - await prisma.user.update({ - where: { id: userId }, +async function enableOutboundReplyTracking({ email }: { email: string }) { + await prisma.emailAccount.update({ + where: { email }, data: { outboundReplyTracking: true }, }); } diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index 3c169b33e5..0d72172021 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -38,7 +38,7 @@ export async function generateDraft({ logger.info("Generating draft"); - const user = await getAiUser({ id: userId }); + const user = await getAiUser({ email: userEmail }); if (!user) throw new Error("User not found"); // 1. Draft with AI @@ -118,7 +118,10 @@ async function generateDraftContent( if (!lastMessage) throw new Error("No message provided"); - const reply = await getReply({ userId: user.id, messageId: lastMessage.id }); + const reply = await getReply({ + userId: user.userId, + messageId: lastMessage.id, + }); if (reply) return reply; @@ -135,7 +138,7 @@ async function generateDraftContent( // 1. Get knowledge base entries const knowledgeBase = await prisma.knowledge.findMany({ - where: { userId: user.id }, + where: { userId: user.userId }, orderBy: { updatedAt: "desc" }, }); @@ -188,7 +191,7 @@ async function generateDraftContent( if (typeof text === "string") { await saveReply({ - userId: user.id, + userId: user.userId, messageId: lastMessage.id, reply: text, }); diff --git a/apps/web/utils/reply-tracker/outbound.ts b/apps/web/utils/reply-tracker/outbound.ts index e841f655da..52c43c7e4d 100644 --- a/apps/web/utils/reply-tracker/outbound.ts +++ b/apps/web/utils/reply-tracker/outbound.ts @@ -18,13 +18,12 @@ export async function handleOutboundReply( ) { const logger = createScopedLogger("reply-tracker/outbound").with({ email: user.email, - userId: user.id, messageId: message.id, threadId: message.threadId, }); // 1. Check if feature enabled - const isEnabled = await isOutboundTrackingEnabled(user.id); + const isEnabled = await isOutboundTrackingEnabled({ email: user.email }); if (!isEnabled) { logger.info("Outbound reply tracking disabled, skipping."); return; @@ -39,7 +38,7 @@ export async function handleOutboundReply( // 3. Resolve existing NEEDS_REPLY trackers for this thread await resolveReplyTrackers( gmail, - user.id, + user.userId, message.threadId, needsReplyLabelId, ); @@ -80,7 +79,7 @@ export async function handleOutboundReply( logger.info("Needs reply. Creating reply tracker outbound"); await createReplyTrackerOutbound({ gmail, - userId: user.id, + userId: user.userId, threadId: message.threadId, messageId: message.id, awaitingReplyLabelId, @@ -176,9 +175,11 @@ async function resolveReplyTrackers( await Promise.allSettled([updateDbPromise, labelPromise]); } -async function isOutboundTrackingEnabled(userId: string): Promise { - const userSettings = await prisma.user.findUnique({ - where: { id: userId }, +async function isOutboundTrackingEnabled({ + email, +}: { email: string }): Promise { + const userSettings = await prisma.emailAccount.findUnique({ + where: { email }, select: { outboundReplyTracking: true }, }); return !!userSettings?.outboundReplyTracking; diff --git a/apps/web/utils/rule/prompt-file.ts b/apps/web/utils/rule/prompt-file.ts index 087c4e7616..cee86b2c07 100644 --- a/apps/web/utils/rule/prompt-file.ts +++ b/apps/web/utils/rule/prompt-file.ts @@ -5,51 +5,62 @@ import { import { generatePromptOnUpdateRule } from "@/utils/ai/rule/generate-prompt-on-update-rule"; import prisma from "@/utils/prisma"; -export async function updatePromptFileOnRuleCreated( - userId: string, - rule: RuleWithRelations, -) { +export async function updatePromptFileOnRuleCreated({ + email, + rule, +}: { + email: string; + rule: RuleWithRelations; +}) { const prompt = createPromptFromRule(rule); - await appendRulePrompt(userId, prompt); + await appendRulePrompt({ email, rulePrompt: prompt }); } -export async function updatePromptFileOnRuleUpdated( - userId: string, - currentRule: RuleWithRelations, - updatedRule: RuleWithRelations, -) { - const user = await prisma.user.findUnique({ - where: { id: userId }, +export async function updatePromptFileOnRuleUpdated({ + email, + currentRule, + updatedRule, +}: { + email: string; + currentRule: RuleWithRelations; + updatedRule: RuleWithRelations; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { email: true, + userId: true, + about: true, aiModel: true, aiProvider: true, aiApiKey: true, rulesPrompt: true, }, }); - if (!user) return; + if (!emailAccount) return; const updatedPrompt = await generatePromptOnUpdateRule({ - user, - existingPrompt: user.rulesPrompt || "", + user: emailAccount, + existingPrompt: emailAccount.rulesPrompt || "", currentRule, updatedRule, }); - await prisma.user.update({ - where: { id: userId }, + await prisma.emailAccount.update({ + where: { email }, data: { rulesPrompt: updatedPrompt }, }); } export async function updateRuleInstructionsAndPromptFile({ userId, + email, ruleId, instructions, currentRule, }: { userId: string; + email: string; ruleId: string; instructions: string; currentRule: RuleWithRelations | null; @@ -62,24 +73,35 @@ export async function updateRuleInstructionsAndPromptFile({ // update prompt file if (currentRule) { - await updatePromptFileOnRuleUpdated(userId, currentRule, updatedRule); + await updatePromptFileOnRuleUpdated({ + email, + currentRule, + updatedRule, + }); } else { - await appendRulePrompt(userId, instructions); + await appendRulePrompt({ email, rulePrompt: instructions }); } } -async function appendRulePrompt(userId: string, rulePrompt: string) { - const user = await prisma.user.findUnique({ - where: { id: userId }, +async function appendRulePrompt({ + email, + rulePrompt, +}: { + email: string; + rulePrompt: string; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { rulesPrompt: true }, }); - if (!user?.rulesPrompt) return; + if (!emailAccount?.rulesPrompt) return; - const updatedPrompt = `${user.rulesPrompt || ""}\n\n* ${rulePrompt}.`.trim(); + const updatedPrompt = + `${emailAccount.rulesPrompt || ""}\n\n* ${rulePrompt}.`.trim(); - await prisma.user.update({ - where: { id: userId }, + await prisma.emailAccount.update({ + where: { email }, data: { rulesPrompt: updatedPrompt }, }); } diff --git a/apps/web/utils/rule/rule.ts b/apps/web/utils/rule/rule.ts index 4c97f54c94..492e206f24 100644 --- a/apps/web/utils/rule/rule.ts +++ b/apps/web/utils/rule/rule.ts @@ -27,12 +27,17 @@ export function partialUpdateRule({ }); } -export async function safeCreateRule( - result: CreateOrUpdateRuleSchemaWithCategories, - userId: string, - categoryNames?: string[] | null, - systemType?: SystemType | null, -) { +export async function safeCreateRule({ + result, + userId, + categoryNames, + systemType, +}: { + result: CreateOrUpdateRuleSchemaWithCategories; + userId: string; + categoryNames?: string[] | null; + systemType?: SystemType | null; +}) { const categoryIds = await getUserCategoriesForNames( userId, categoryNames || [], diff --git a/apps/web/utils/upstash/categorize-senders.ts b/apps/web/utils/upstash/categorize-senders.ts index 92f89f5885..a7c1c7db45 100644 --- a/apps/web/utils/upstash/categorize-senders.ts +++ b/apps/web/utils/upstash/categorize-senders.ts @@ -8,8 +8,8 @@ const logger = createScopedLogger("upstash"); const CATEGORIZE_SENDERS_PREFIX = "ai-categorize-senders"; -const getCategorizeSendersQueueName = (userId: string) => - `${CATEGORIZE_SENDERS_PREFIX}-${userId}`; +const getCategorizeSendersQueueName = ({ email }: { email: string }) => + `${CATEGORIZE_SENDERS_PREFIX}-${email}`; /** * Publishes sender categorization tasks to QStash queue in batches @@ -25,7 +25,7 @@ export async function publishToAiCategorizeSendersQueue( const chunks = chunk(body.senders, BATCH_SIZE); // Create new queue for each user so we can run multiple users in parallel - const queueName = getCategorizeSendersQueueName(body.userId); + const queueName = getCategorizeSendersQueueName({ email: body.email }); logger.info("Publishing to AI categorize senders queue in chunks", { url, @@ -41,26 +41,41 @@ export async function publishToAiCategorizeSendersQueue( queueName, parallelism: 3, // Allow up to 3 concurrent jobs from this queue url, - body: { userId: body.userId, senders: senderChunk }, + body: { + email: body.email, + senders: senderChunk, + } satisfies AiCategorizeSenders, }), ), ); } export async function deleteEmptyCategorizeSendersQueues({ - skipUserId, + skipEmail, }: { - skipUserId: string; + skipEmail: string; }) { - return deleteEmptyQueues(CATEGORIZE_SENDERS_PREFIX, skipUserId); + return deleteEmptyQueues({ + prefix: CATEGORIZE_SENDERS_PREFIX, + skipEmail, + }); } -async function deleteEmptyQueues(prefix: string, skipUserId: string) { +async function deleteEmptyQueues({ + prefix, + skipEmail, +}: { + prefix: string; + skipEmail: string; +}) { const queues = await listQueues(); logger.info("Found queues", { count: queues.length }); for (const queue of queues) { if (!queue.name.startsWith(prefix)) continue; - if (skipUserId && queue.name === getCategorizeSendersQueueName(skipUserId)) + if ( + skipEmail && + queue.name === getCategorizeSendersQueueName({ email: skipEmail }) + ) continue; if (!queue.lag) { diff --git a/apps/web/utils/user/get.ts b/apps/web/utils/user/get.ts index a93dec78aa..871c856fc0 100644 --- a/apps/web/utils/user/get.ts +++ b/apps/web/utils/user/get.ts @@ -1,10 +1,13 @@ import prisma from "@/utils/prisma"; +import type { UserEmailWithAI } from "@/utils/llms/types"; -export async function getAiUser({ id }: { id: string }) { - return prisma.user.findUnique({ - where: { id }, +export async function getAiUser({ + email, +}: { email: string }): Promise { + return prisma.emailAccount.findUnique({ + where: { email }, select: { - id: true, + userId: true, email: true, about: true, aiProvider: true, @@ -14,18 +17,17 @@ export async function getAiUser({ id }: { id: string }) { }); } -export async function getAiUserWithTokens({ id }: { id: string }) { - const user = await prisma.user.findUnique({ - where: { id }, +export async function getAiUserWithTokens({ email }: { email: string }) { + const user = await prisma.emailAccount.findUnique({ + where: { email }, select: { - id: true, + userId: true, email: true, about: true, aiProvider: true, aiModel: true, aiApiKey: true, - accounts: { - take: 1, + account: { select: { access_token: true, refresh_token: true, @@ -38,7 +40,7 @@ export async function getAiUserWithTokens({ id }: { id: string }) { return { ...user, - tokens: user?.accounts[0], + tokens: user?.account, }; } diff --git a/apps/web/utils/user/validate.ts b/apps/web/utils/user/validate.ts index f8d1bfb446..ad7f571057 100644 --- a/apps/web/utils/user/validate.ts +++ b/apps/web/utils/user/validate.ts @@ -1,26 +1,30 @@ import { hasAiAccess } from "@/utils/premium"; import prisma from "@/utils/prisma"; -export async function validateUserAndAiAccess(userId: string) { - const user = await prisma.user.findUnique({ - where: { id: userId }, +export async function validateUserAndAiAccess({ + email, +}: { + email: string; +}) { + const emailAccount = await prisma.emailAccount.findUnique({ + where: { email }, select: { - id: true, + userId: true, email: true, aiProvider: true, aiModel: true, aiApiKey: true, about: true, - premium: { select: { aiAutomationAccess: true } }, + user: { select: { premium: { select: { aiAutomationAccess: true } } } }, }, }); - if (!user) return { error: "User not found" }; + if (!emailAccount) return { error: "User not found" }; const userHasAiAccess = hasAiAccess( - user.premium?.aiAutomationAccess, - user.aiApiKey, + emailAccount.user.premium?.aiAutomationAccess, + emailAccount.aiApiKey, ); if (!userHasAiAccess) return { error: "Please upgrade for AI access" }; - return { user }; + return { emailAccount }; } From 10cab47da4920def35bb9a14248d9429b58e22dc Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:38:39 +0300 Subject: [PATCH 02/21] fix more ts errors --- apps/web/app/api/unsubscribe/route.ts | 4 +- .../api/user/settings/email-updates/route.ts | 2 +- .../reply-tracker/check-previous-emails.ts | 2 +- .../web/utils/reply-tracker/generate-draft.ts | 4 +- apps/web/utils/reply-tracker/inbound.ts | 39 +++++++++++-------- apps/web/utils/user/delete.ts | 2 +- apps/web/utils/webhook.ts | 6 +-- 7 files changed, 33 insertions(+), 26 deletions(-) diff --git a/apps/web/app/api/unsubscribe/route.ts b/apps/web/app/api/unsubscribe/route.ts index 8967bad5f6..38a881d96b 100644 --- a/apps/web/app/api/unsubscribe/route.ts +++ b/apps/web/app/api/unsubscribe/route.ts @@ -50,8 +50,8 @@ async function unsubscribe(request: Request) { // Update user preferences const [userUpdate, tokenDelete] = await Promise.allSettled([ - prisma.user.update({ - where: { id: emailToken.userId }, + prisma.emailAccount.update({ + where: { email: emailToken.user.email }, data: { summaryEmailFrequency: Frequency.NEVER, statsEmailFrequency: Frequency.NEVER, diff --git a/apps/web/app/api/user/settings/email-updates/route.ts b/apps/web/app/api/user/settings/email-updates/route.ts index 80c35d7575..1e294f188e 100644 --- a/apps/web/app/api/user/settings/email-updates/route.ts +++ b/apps/web/app/api/user/settings/email-updates/route.ts @@ -16,7 +16,7 @@ async function saveEmailUpdateSettings(options: SaveEmailUpdateSettingsBody) { const session = await auth(); if (!session?.user.email) throw new SafeError("Not logged in"); - return await prisma.user.update({ + return await prisma.emailAccount.update({ where: { email: session.user.email }, data: { statsEmailFrequency: options.statsEmailFrequency, diff --git a/apps/web/utils/reply-tracker/check-previous-emails.ts b/apps/web/utils/reply-tracker/check-previous-emails.ts index bd4e920b28..e2f264224a 100644 --- a/apps/web/utils/reply-tracker/check-previous-emails.ts +++ b/apps/web/utils/reply-tracker/check-previous-emails.ts @@ -47,7 +47,7 @@ export async function processPreviousSentEmails( const isProcessed = await prisma.executedRule.findUnique({ where: { unique_user_thread_message: { - userId: user.id, + userId: user.userId, threadId: latestMessage.threadId, messageId: latestMessage.id, }, diff --git a/apps/web/utils/reply-tracker/generate-draft.ts b/apps/web/utils/reply-tracker/generate-draft.ts index 0d72172021..1344cf28cb 100644 --- a/apps/web/utils/reply-tracker/generate-draft.ts +++ b/apps/web/utils/reply-tracker/generate-draft.ts @@ -119,7 +119,7 @@ async function generateDraftContent( if (!lastMessage) throw new Error("No message provided"); const reply = await getReply({ - userId: user.userId, + email: user.email, messageId: lastMessage.id, }); @@ -191,7 +191,7 @@ async function generateDraftContent( if (typeof text === "string") { await saveReply({ - userId: user.userId, + email: user.email, messageId: lastMessage.id, reply: text, }); diff --git a/apps/web/utils/reply-tracker/inbound.ts b/apps/web/utils/reply-tracker/inbound.ts index 7bf36ca664..2509791aba 100644 --- a/apps/web/utils/reply-tracker/inbound.ts +++ b/apps/web/utils/reply-tracker/inbound.ts @@ -16,14 +16,21 @@ import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; * 1. Updating thread trackers in the database * 2. Managing Gmail labels */ -export async function coordinateReplyProcess( - userId: string, - email: string, - threadId: string, - messageId: string, - sentAt: Date, - gmail: gmail_v1.Gmail, -) { +export async function coordinateReplyProcess({ + userId, + email, + threadId, + messageId, + sentAt, + gmail, +}: { + userId: string; + email: string; + threadId: string; + messageId: string; + sentAt: Date; + gmail: gmail_v1.Gmail; +}) { const logger = createScopedLogger("reply-tracker/inbound").with({ userId, email, @@ -113,7 +120,7 @@ export async function handleInboundReply( const replyTrackingRules = await prisma.rule.findMany({ where: { - userId: user.id, + userId: user.userId, instructions: { not: null }, actions: { some: { @@ -136,13 +143,13 @@ export async function handleInboundReply( }); if (replyTrackingRules.some((rule) => rule.id === result.rule?.id)) { - await coordinateReplyProcess( - user.id, - user.email, - message.threadId, - message.id, - internalDateToDate(message.internalDate), + await coordinateReplyProcess({ + userId: user.userId, + email: user.email, + threadId: message.threadId, + messageId: message.id, + sentAt: internalDateToDate(message.internalDate), gmail, - ); + }); } } diff --git a/apps/web/utils/user/delete.ts b/apps/web/utils/user/delete.ts index 45c3d327af..17ba6fa354 100644 --- a/apps/web/utils/user/delete.ts +++ b/apps/web/utils/user/delete.ts @@ -38,7 +38,7 @@ export async function deleteUser({ deleteResendContact({ email }), account ? unwatchEmails({ - userId: userId, + email, access_token: account.access_token ?? null, refresh_token: null, }) diff --git a/apps/web/utils/webhook.ts b/apps/web/utils/webhook.ts index 848463e132..28658bae6e 100644 --- a/apps/web/utils/webhook.ts +++ b/apps/web/utils/webhook.ts @@ -28,11 +28,11 @@ export const callWebhook = async ( ) => { if (!url) throw new Error("Webhook URL is required"); - const user = await prisma.user.findUnique({ + const emailAccount = await prisma.emailAccount.findUnique({ where: { email: userEmail }, select: { webhookSecret: true }, }); - if (!user) throw new Error("User not found"); + if (!emailAccount) throw new Error("Email account not found"); try { await Promise.race([ @@ -40,7 +40,7 @@ export const callWebhook = async ( method: "POST", headers: { "Content-Type": "application/json", - "X-Webhook-Secret": user?.webhookSecret || "", + "X-Webhook-Secret": emailAccount.webhookSecret || "", }, body: JSON.stringify(payload), }), From f18fbf8586482e89b3da1f716d9ee1701a36db0a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:46:34 +0300 Subject: [PATCH 03/21] test getuser helper --- .../__tests__/ai-categorize-senders.test.ts | 2 ++ apps/web/__tests__/ai-choose-args.test.ts | 12 +------ apps/web/__tests__/ai-choose-rule.test.ts | 11 +----- .../ai-detect-recurring-pattern.test.ts | 12 +------ .../ai-extract-from-email-history.test.ts | 12 +------ apps/web/__tests__/ai-find-snippets.test.ts | 11 +----- .../__tests__/ai-process-user-request.test.ts | 12 +------ apps/web/__tests__/ai-prompt-to-rules.test.ts | 14 +++++++- apps/web/__tests__/ai-rule-fix.test.ts | 11 +----- .../ai/reply/draft-with-knowledge.test.ts | 2 +- apps/web/__tests__/helpers.ts | 10 ++++++ apps/web/__tests__/writing-style.test.ts | 12 +------ .../utils/ai/choose-rule/match-rules.test.ts | 12 +------ apps/web/utils/ai/choose-rule/run-rules.ts | 26 +++++++++----- .../assistant/process-assistant-email.ts | 36 ++++++++++--------- .../utils/cold-email/is-cold-email.test.ts | 13 +++++-- 16 files changed, 83 insertions(+), 125 deletions(-) create mode 100644 apps/web/__tests__/helpers.ts diff --git a/apps/web/__tests__/ai-categorize-senders.test.ts b/apps/web/__tests__/ai-categorize-senders.test.ts index 5973c6aba5..d0f070e92a 100644 --- a/apps/web/__tests__/ai-categorize-senders.test.ts +++ b/apps/web/__tests__/ai-categorize-senders.test.ts @@ -13,10 +13,12 @@ const isAiTest = process.env.RUN_AI_TESTS === "true"; vi.mock("server-only", () => ({})); const testUser = { + userId: "user1", email: "user@test.com", aiProvider: null, aiModel: null, aiApiKey: null, + about: null, }; const testSenders = [ diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts index b80a1334b1..e0979f4cf1 100644 --- a/apps/web/__tests__/ai-choose-args.test.ts +++ b/apps/web/__tests__/ai-choose-args.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { type Action, ActionType, LogicalOperator } from "@prisma/client"; import type { ParsedMessage, RuleWithActions } from "@/utils/types"; import { getActionItemsWithAiArgs } from "@/utils/ai/choose-rule/choose-args"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-args @@ -228,14 +229,3 @@ function getParsedMessage({ }, }; } - -function getUser() { - return { - id: "userId", - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index dedb94f47a..b2caa11113 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { type Action, ActionType, LogicalOperator } from "@prisma/client"; import { defaultReplyTrackerInstructions } from "@/utils/reply-tracker/consts"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-rule @@ -360,13 +361,3 @@ function getEmail({ content, }; } - -function getUser() { - return { - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} diff --git a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts index 0edc77622b..374a246923 100644 --- a/apps/web/__tests__/ai-detect-recurring-pattern.test.ts +++ b/apps/web/__tests__/ai-detect-recurring-pattern.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiDetectRecurringPattern } from "@/utils/ai/choose-rule/ai-detect-recurring-pattern"; import type { EmailForLLM } from "@/utils/types"; import { RuleName } from "@/utils/rule/consts"; +import { getUser } from "@/__tests__/helpers"; // Run with: pnpm test-ai ai-detect-recurring-pattern @@ -28,17 +29,6 @@ describe.runIf(isAiTest)( vi.clearAllMocks(); }); - function getUser() { - return { - id: "user-1", - email: "user@test.com", - aiModel: null, - aiProvider: null, - aiApiKey: null, - about: null, - }; - } - function getRealisticRules() { return [ { diff --git a/apps/web/__tests__/ai-extract-from-email-history.test.ts b/apps/web/__tests__/ai-extract-from-email-history.test.ts index 867b3f1980..7b2377cad5 100644 --- a/apps/web/__tests__/ai-extract-from-email-history.test.ts +++ b/apps/web/__tests__/ai-extract-from-email-history.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiExtractFromEmailHistory } from "@/utils/ai/knowledge/extract-from-email-history"; import type { EmailForLLM } from "@/utils/types"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai extract-from-email-history @@ -9,17 +10,6 @@ vi.mock("server-only", () => ({})); // Skip tests unless explicitly running AI tests const isAiTest = process.env.RUN_AI_TESTS === "true"; -function getUser() { - return { - id: "test-user-id", - email: "user@test.com", - aiModel: null, - aiProvider: null, - aiApiKey: null, - about: null, - }; -} - function getMockMessage(overrides = {}): EmailForLLM { return { id: "msg1", diff --git a/apps/web/__tests__/ai-find-snippets.test.ts b/apps/web/__tests__/ai-find-snippets.test.ts index edaaa311f1..6055f39f5d 100644 --- a/apps/web/__tests__/ai-find-snippets.test.ts +++ b/apps/web/__tests__/ai-find-snippets.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { aiFindSnippets } from "@/utils/ai/snippets/find-snippets"; import type { EmailForLLM } from "@/utils/types"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-find-snippets const isAiTest = process.env.RUN_AI_TESTS === "true"; @@ -80,13 +81,3 @@ function getEmail({ ...(cc && { cc }), }; } - -function getUser() { - return { - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} diff --git a/apps/web/__tests__/ai-process-user-request.test.ts b/apps/web/__tests__/ai-process-user-request.test.ts index 288953a42a..92bb1ad1d7 100644 --- a/apps/web/__tests__/ai-process-user-request.test.ts +++ b/apps/web/__tests__/ai-process-user-request.test.ts @@ -5,6 +5,7 @@ import type { ParsedMessage, ParsedMessageHeaders } from "@/utils/types"; import type { RuleWithRelations } from "@/utils/ai/rule/create-prompt-from-rule"; import type { Category, GroupItem, Prisma } from "@prisma/client"; import { GroupItemType, LogicalOperator } from "@prisma/client"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-process-user-request @@ -477,17 +478,6 @@ function getParsedMessage( }; } -function getUser() { - return { - id: "user1", - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} - type Group = Prisma.GroupGetPayload<{ select: { id: true; diff --git a/apps/web/__tests__/ai-prompt-to-rules.test.ts b/apps/web/__tests__/ai-prompt-to-rules.test.ts index ea512f1740..f132bc0b43 100644 --- a/apps/web/__tests__/ai-prompt-to-rules.test.ts +++ b/apps/web/__tests__/ai-prompt-to-rules.test.ts @@ -16,6 +16,8 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { aiModel: null, aiProvider: null, aiApiKey: null, + userId: "user123", + about: null, }; const prompts = [ @@ -114,6 +116,8 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { aiProvider: null, aiModel: null, aiApiKey: "invalid-api-key", + about: null, + userId: "user123", }; const promptFile = "Some prompt"; @@ -132,6 +136,8 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { aiModel: null, aiProvider: null, aiApiKey: null, + about: null, + userId: "user123", }; const promptFile = ` @@ -207,6 +213,8 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { aiModel: null, aiProvider: null, aiApiKey: null, + about: null, + userId: "user123", }; const promptFile = ` @@ -249,6 +257,8 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { aiModel: null, aiProvider: null, aiApiKey: null, + about: null, + userId: "user123", }; const promptFile = ` @@ -291,6 +301,8 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { aiModel: null, aiProvider: null, aiApiKey: null, + about: null, + userId: "user123", }; const promptFile = ` @@ -329,6 +341,6 @@ describe.runIf(isAiTest)("aiPromptToRules", () => { const replyAction = result[0].actions.find( (a) => a.type === ActionType.REPLY, ); - expect(replyAction?.content).toContain("{{firstName}}"); + expect(replyAction?.fields?.content).toContain("{{firstName}}"); }, 15_000); }); diff --git a/apps/web/__tests__/ai-rule-fix.test.ts b/apps/web/__tests__/ai-rule-fix.test.ts index 7773a7f925..30a1cae281 100644 --- a/apps/web/__tests__/ai-rule-fix.test.ts +++ b/apps/web/__tests__/ai-rule-fix.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import stripIndent from "strip-indent"; import { aiRuleFix } from "@/utils/ai/rule/rule-fix"; import type { EmailForLLM } from "@/utils/types"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-rule-fix @@ -177,13 +178,3 @@ function getEmail({ ...(cc && { cc }), }; } - -function getUser() { - return { - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} diff --git a/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts b/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts index 34fe042522..410112000b 100644 --- a/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts +++ b/apps/web/__tests__/ai/reply/draft-with-knowledge.test.ts @@ -64,7 +64,7 @@ describe.runIf(isAiTest)("aiDraftWithKnowledge", () => { function getUser(overrides: Partial = {}): UserEmailWithAI { return { - id: "user-123", + userId: "user-123", email: "user@example.com", aiModel: null, aiProvider: null, diff --git a/apps/web/__tests__/helpers.ts b/apps/web/__tests__/helpers.ts new file mode 100644 index 0000000000..716ff8a226 --- /dev/null +++ b/apps/web/__tests__/helpers.ts @@ -0,0 +1,10 @@ +export function getUser() { + return { + userId: "user1", + email: "user@test.com", + aiModel: null, + aiProvider: null, + aiApiKey: null, + about: null, + }; +} diff --git a/apps/web/__tests__/writing-style.test.ts b/apps/web/__tests__/writing-style.test.ts index b6cb921932..6be330972a 100644 --- a/apps/web/__tests__/writing-style.test.ts +++ b/apps/web/__tests__/writing-style.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiAnalyzeWritingStyle } from "@/utils/ai/knowledge/writing-style"; +import { getUser } from "@/__tests__/helpers"; // Run with: pnpm test-ai writing-style @@ -40,17 +41,6 @@ describe.runIf(isAiTest)( 15_000, ); // 15 second timeout -function getUser() { - return { - id: "test-user-id", - email: "user@test.com", - aiModel: null, - aiProvider: null, - aiApiKey: null, - about: null, - }; -} - function getTestEmails() { return [ { diff --git a/apps/web/utils/ai/choose-rule/match-rules.test.ts b/apps/web/utils/ai/choose-rule/match-rules.test.ts index f358a5eb3b..dd9ae75852 100644 --- a/apps/web/utils/ai/choose-rule/match-rules.test.ts +++ b/apps/web/utils/ai/choose-rule/match-rules.test.ts @@ -17,6 +17,7 @@ import type { } from "@/utils/types"; import prisma from "@/utils/__mocks__/prisma"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; +import { getUser } from "@/__tests__/helpers"; // Run with: // pnpm test match-rules.test.ts @@ -492,17 +493,6 @@ function getHeaders( } as ParsedMessageHeaders; } -function getUser() { - return { - id: "user1", - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} - function getMessage(overrides: Partial = {}): ParsedMessage { const message = { id: "m1", diff --git a/apps/web/utils/ai/choose-rule/run-rules.ts b/apps/web/utils/ai/choose-rule/run-rules.ts index 93ef3c0d0a..cee0a155bf 100644 --- a/apps/web/utils/ai/choose-rule/run-rules.ts +++ b/apps/web/utils/ai/choose-rule/run-rules.ts @@ -47,7 +47,12 @@ export async function runRules({ }): Promise { const result = await findMatchingRule(rules, message, user, gmail); - analyzeSenderPatternIfAiMatch(isTest, result, message, user.userId); + analyzeSenderPatternIfAiMatch({ + isTest, + result, + message, + email: user.email, + }); logger.trace("Matching rule", { result }); @@ -243,12 +248,17 @@ async function upsertExecutedRule({ } } -async function analyzeSenderPatternIfAiMatch( - isTest: boolean, - result: { rule?: Rule | null; matchReasons?: MatchReason[] }, - message: ParsedMessage, - userId: string, -) { +async function analyzeSenderPatternIfAiMatch({ + isTest, + result, + message, + email, +}: { + isTest: boolean; + result: { rule?: Rule | null; matchReasons?: MatchReason[] }; + message: ParsedMessage; + email: string; +}) { if ( !isTest && result.rule && @@ -265,7 +275,7 @@ async function analyzeSenderPatternIfAiMatch( if (fromAddress) { after(() => analyzeSenderPattern({ - userId, + email, from: fromAddress, }), ); diff --git a/apps/web/utils/assistant/process-assistant-email.ts b/apps/web/utils/assistant/process-assistant-email.ts index e498a58522..55621f12e1 100644 --- a/apps/web/utils/assistant/process-assistant-email.ts +++ b/apps/web/utils/assistant/process-assistant-email.ts @@ -99,35 +99,39 @@ async function processAssistantEmailInternal({ const originalMessage = await getOriginalMessage(originalMessageId, gmail); const [user, executedRule, senderCategory] = await Promise.all([ - prisma.user.findUnique({ + prisma.emailAccount.findUnique({ where: { email: userEmail }, select: { - id: true, + userId: true, email: true, about: true, aiProvider: true, aiModel: true, aiApiKey: true, - rules: { - include: { - actions: true, - categoryFilters: true, - group: { - select: { - id: true, - name: true, - items: { + user: { + select: { + rules: { + include: { + actions: true, + categoryFilters: true, + group: { select: { id: true, - type: true, - value: true, + name: true, + items: { + select: { + id: true, + type: true, + value: true, + }, + }, }, }, }, }, + categories: true, }, }, - categories: true, }, }), originalMessage @@ -210,11 +214,11 @@ async function processAssistantEmailInternal({ const result = await processUserRequest({ user, - rules: user.rules, + rules: user.user.rules, originalEmail: originalMessage, messages, matchedRule: executedRule?.rule || null, - categories: user.categories.length ? user.categories : null, + categories: user.user.categories.length ? user.user.categories : null, senderCategory: senderCategory?.category?.name ?? null, }); diff --git a/apps/web/utils/cold-email/is-cold-email.test.ts b/apps/web/utils/cold-email/is-cold-email.test.ts index a6502118ed..6841c013dd 100644 --- a/apps/web/utils/cold-email/is-cold-email.test.ts +++ b/apps/web/utils/cold-email/is-cold-email.test.ts @@ -34,9 +34,13 @@ describe("blockColdEmail", () => { threadId: "thread123", }; const mockUser = { - id: "user123", + userId: "user123", + about: "", email: "user@example.com", coldEmailBlocker: ColdEmailSetting.LABEL, + aiProvider: null, + aiModel: null, + aiApiKey: null, }; const mockAiReason = "This is a cold email"; @@ -54,13 +58,16 @@ describe("blockColdEmail", () => { expect(prisma.coldEmail.upsert).toHaveBeenCalledWith({ where: { - userId_fromEmail: { userId: mockUser.id, fromEmail: mockEmail.from }, + userId_fromEmail: { + userId: mockUser.userId, + fromEmail: mockEmail.from, + }, }, update: { status: ColdEmailStatus.AI_LABELED_COLD }, create: { status: ColdEmailStatus.AI_LABELED_COLD, fromEmail: mockEmail.from, - userId: mockUser.id, + userId: mockUser.userId, reason: mockAiReason, messageId: mockEmail.id, threadId: mockEmail.threadId, From d49f7a7a2794f8bd9d726ed871914f7b696ca3d2 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:51:07 +0300 Subject: [PATCH 04/21] get email test helper --- apps/web/__tests__/ai-choose-args.test.ts | 2 +- apps/web/__tests__/ai-choose-rule.test.ts | 15 +-------------- apps/web/__tests__/ai-find-snippets.test.ts | 19 +------------------ apps/web/__tests__/ai-rule-fix.test.ts | 18 +----------------- apps/web/__tests__/helpers.ts | 19 +++++++++++++++++++ 5 files changed, 23 insertions(+), 50 deletions(-) diff --git a/apps/web/__tests__/ai-choose-args.test.ts b/apps/web/__tests__/ai-choose-args.test.ts index e0979f4cf1..7b146482c3 100644 --- a/apps/web/__tests__/ai-choose-args.test.ts +++ b/apps/web/__tests__/ai-choose-args.test.ts @@ -198,7 +198,7 @@ function getRule( enabled: true, categoryFilterType: null, conditionalOperator: LogicalOperator.AND, - type: null, + systemType: null, }; } diff --git a/apps/web/__tests__/ai-choose-rule.test.ts b/apps/web/__tests__/ai-choose-rule.test.ts index b2caa11113..421c78c482 100644 --- a/apps/web/__tests__/ai-choose-rule.test.ts +++ b/apps/web/__tests__/ai-choose-rule.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import { aiChooseRule } from "@/utils/ai/choose-rule/ai-choose-rule"; import { type Action, ActionType, LogicalOperator } from "@prisma/client"; import { defaultReplyTrackerInstructions } from "@/utils/reply-tracker/consts"; -import { getUser } from "@/__tests__/helpers"; +import { getEmail, getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-rule @@ -348,16 +348,3 @@ function getRule(instructions: string, actions: Action[] = []) { conditionalOperator: LogicalOperator.AND, }; } - -function getEmail({ - from = "from@test.com", - subject = "subject", - content = "content", -}: { from?: string; subject?: string; content?: string } = {}) { - return { - id: "id", - from, - subject, - content, - }; -} diff --git a/apps/web/__tests__/ai-find-snippets.test.ts b/apps/web/__tests__/ai-find-snippets.test.ts index 6055f39f5d..fbc83c5267 100644 --- a/apps/web/__tests__/ai-find-snippets.test.ts +++ b/apps/web/__tests__/ai-find-snippets.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi } from "vitest"; import { aiFindSnippets } from "@/utils/ai/snippets/find-snippets"; import type { EmailForLLM } from "@/utils/types"; -import { getUser } from "@/__tests__/helpers"; +import { getEmail, getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-find-snippets const isAiTest = process.env.RUN_AI_TESTS === "true"; @@ -64,20 +64,3 @@ describe.runIf(isAiTest)("aiFindSnippets", () => { expect(result.snippets).toHaveLength(0); }); }); - -// helpers -function getEmail({ - from = "user@test.com", - subject = "Test Subject", - content = "Test content", - replyTo, - cc, -}: Partial = {}): EmailForLLM { - return { - from, - subject, - content, - ...(replyTo && { replyTo }), - ...(cc && { cc }), - }; -} diff --git a/apps/web/__tests__/ai-rule-fix.test.ts b/apps/web/__tests__/ai-rule-fix.test.ts index 30a1cae281..2997e3d7ca 100644 --- a/apps/web/__tests__/ai-rule-fix.test.ts +++ b/apps/web/__tests__/ai-rule-fix.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test, vi } from "vitest"; import stripIndent from "strip-indent"; import { aiRuleFix } from "@/utils/ai/rule/rule-fix"; import type { EmailForLLM } from "@/utils/types"; -import { getUser } from "@/__tests__/helpers"; +import { getEmail, getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-rule-fix @@ -162,19 +162,3 @@ describe.runIf(isAiTest)("aiRuleFix", () => { ); }); }); - -function getEmail({ - from = "user@test.com", - subject = "Test Subject", - content = "Test content", - replyTo, - cc, -}: Partial = {}): EmailForLLM { - return { - from, - subject, - content, - ...(replyTo && { replyTo }), - ...(cc && { cc }), - }; -} diff --git a/apps/web/__tests__/helpers.ts b/apps/web/__tests__/helpers.ts index 716ff8a226..4c12a197a9 100644 --- a/apps/web/__tests__/helpers.ts +++ b/apps/web/__tests__/helpers.ts @@ -1,3 +1,5 @@ +import type { EmailForLLM } from "@/utils/types"; + export function getUser() { return { userId: "user1", @@ -8,3 +10,20 @@ export function getUser() { about: null, }; } + +export function getEmail({ + from = "user@test.com", + subject = "Test Subject", + content = "Test content", + replyTo, + cc, +}: Partial = {}): EmailForLLM { + return { + id: "email-id", + from, + subject, + content, + ...(replyTo && { replyTo }), + ...(cc && { cc }), + }; +} From 519c32eb76ec3e5bfb5b395620ff2aceb9aac18a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:55:45 +0300 Subject: [PATCH 05/21] default ai provider fix --- apps/web/app/api/user/settings/route.ts | 13 ++++++------- apps/web/utils/ai/actions.ts | 14 +++++++------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/apps/web/app/api/user/settings/route.ts b/apps/web/app/api/user/settings/route.ts index 777733adfb..6c42e189eb 100644 --- a/apps/web/app/api/user/settings/route.ts +++ b/apps/web/app/api/user/settings/route.ts @@ -13,12 +13,11 @@ export type SaveSettingsResponse = Awaited>; async function saveAISettings(options: SaveSettingsBody) { const session = await auth(); - if (!session?.user.email) throw new SafeError("Not logged in"); - - const aiProvider = options.aiProvider || Provider.ANTHROPIC; + const email = session?.user.email; + if (!email) throw new SafeError("Not logged in"); function getModel() { - switch (aiProvider) { + switch (options.aiProvider) { case Provider.OPEN_AI: if (!options.aiApiKey) throw new SafeError("OpenAI API key is required"); @@ -46,10 +45,10 @@ async function saveAISettings(options: SaveSettingsBody) { } } - return await prisma.user.update({ - where: { email: session.user.email }, + return await prisma.emailAccount.update({ + where: { email }, data: { - aiProvider, + aiProvider: options.aiProvider, aiModel: getModel(), aiApiKey: options.aiApiKey || null, }, diff --git a/apps/web/utils/ai/actions.ts b/apps/web/utils/ai/actions.ts index 50693e72dd..ac5ed5f583 100644 --- a/apps/web/utils/ai/actions.ts +++ b/apps/web/utils/ai/actions.ts @@ -217,14 +217,14 @@ const track_thread: ActionFunction = async ( userEmail, executedRule, ) => { - await coordinateReplyProcess( - executedRule.userId, - userEmail, - email.threadId, - email.id, - internalDateToDate(email.internalDate), + await coordinateReplyProcess({ + userId: executedRule.userId, + email: userEmail, + threadId: email.threadId, + messageId: email.id, + sentAt: internalDateToDate(email.internalDate), gmail, - ).catch((error) => { + }).catch((error) => { logger.error("Failed to create reply tracker", { error }); }); }; From 28c7b50034493052b3e708e15f732ededf65f5a0 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 13:57:39 +0300 Subject: [PATCH 06/21] more ts fixes --- apps/web/__tests__/ai-diff-rules.test.ts | 15 +++------------ apps/web/__tests__/ai-example-matches.test.ts | 9 ++------- apps/web/__tests__/ai-extract-knowledge.test.ts | 12 +----------- .../web/__tests__/ai-process-user-request.test.ts | 1 + 4 files changed, 7 insertions(+), 30 deletions(-) diff --git a/apps/web/__tests__/ai-diff-rules.test.ts b/apps/web/__tests__/ai-diff-rules.test.ts index d754ecf14a..55aa648cb5 100644 --- a/apps/web/__tests__/ai-diff-rules.test.ts +++ b/apps/web/__tests__/ai-diff-rules.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { aiDiffRules } from "@/utils/ai/rule/diff-rules"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-diff-rules @@ -9,12 +10,7 @@ vi.mock("server-only", () => ({})); describe.runIf(isAiTest)("aiDiffRules", () => { it("should correctly identify added, edited, and removed rules", async () => { - const user = { - email: "user@test.com", - aiModel: null, - aiProvider: null, - aiApiKey: null, - }; + const user = getUser(); const oldPromptFile = ` * Label receipts as "Receipt" @@ -45,12 +41,7 @@ describe.runIf(isAiTest)("aiDiffRules", () => { }, 15_000); it("should handle errors gracefully", async () => { - const user = { - email: "test@example.com", - aiProvider: null, - aiModel: null, - aiApiKey: "invalid-api-key", - }; + const user = { ...getUser(), aiApiKey: "invalid-api-key" }; const oldPromptFile = "Some old prompt"; const newPromptFile = "Some new prompt"; diff --git a/apps/web/__tests__/ai-example-matches.test.ts b/apps/web/__tests__/ai-example-matches.test.ts index f6ae05ccb3..38fbf74649 100644 --- a/apps/web/__tests__/ai-example-matches.test.ts +++ b/apps/web/__tests__/ai-example-matches.test.ts @@ -4,6 +4,7 @@ import { aiFindExampleMatches } from "@/utils/ai/example-matches/find-example-ma import { queryBatchMessages } from "@/utils/gmail/message"; import type { ParsedMessage } from "@/utils/types"; import { findExampleMatchesSchema } from "@/utils/ai/example-matches/find-example-matches"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-find-example-matches @@ -16,15 +17,9 @@ vi.mock("@/utils/gmail/message", () => ({ describe.runIf(isAiTest)("aiFindExampleMatches", () => { it("should find example matches based on user prompt", async () => { - const user = { - email: "user@test.com", - aiProvider: null, - aiModel: null, - aiApiKey: null, - }; + const user = getUser(); const gmail = {} as gmail_v1.Gmail; - const accessToken = "fake-access-token"; const rulesPrompt = ` * Label newsletters as "Newsletter" and archive them. * Label emails that require a reply as "Reply Required". diff --git a/apps/web/__tests__/ai-extract-knowledge.test.ts b/apps/web/__tests__/ai-extract-knowledge.test.ts index 8c254c18a1..6b66b7fb0d 100644 --- a/apps/web/__tests__/ai-extract-knowledge.test.ts +++ b/apps/web/__tests__/ai-extract-knowledge.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test, vi, beforeEach } from "vitest"; import { aiExtractRelevantKnowledge } from "@/utils/ai/knowledge/extract"; import type { Knowledge } from "@prisma/client"; import type { UserEmailWithAI } from "@/utils/llms/types"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-extract-knowledge @@ -10,17 +11,6 @@ vi.mock("server-only", () => ({})); // Skip tests unless explicitly running AI tests const isAiTest = process.env.RUN_AI_TESTS === "true"; -function getUser(): UserEmailWithAI { - return { - id: "test-user-id", - email: "influencer@test.com", - about: null, - aiModel: null, - aiProvider: null, - aiApiKey: null, - }; -} - function getKnowledgeBase(): Knowledge[] { return [ { diff --git a/apps/web/__tests__/ai-process-user-request.test.ts b/apps/web/__tests__/ai-process-user-request.test.ts index 92bb1ad1d7..04cd8b08fe 100644 --- a/apps/web/__tests__/ai-process-user-request.test.ts +++ b/apps/web/__tests__/ai-process-user-request.test.ts @@ -446,6 +446,7 @@ function getRule(rule: Partial): RuleWithRelations { enabled: true, createdAt: new Date(), updatedAt: new Date(), + systemType: null, ...rule, }; } From 7468878d581d0581366c99c203c388d4d495789d Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 14:01:04 +0300 Subject: [PATCH 07/21] fix ts test --- apps/web/__tests__/ai-create-group.test.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/web/__tests__/ai-create-group.test.ts b/apps/web/__tests__/ai-create-group.test.ts index 45cd821986..5e34e30e7b 100644 --- a/apps/web/__tests__/ai-create-group.test.ts +++ b/apps/web/__tests__/ai-create-group.test.ts @@ -3,6 +3,7 @@ import type { gmail_v1 } from "@googleapis/gmail"; import { aiGenerateGroupItems } from "@/utils/ai/group/create-group"; import { queryBatchMessages } from "@/utils/gmail/message"; import type { ParsedMessage } from "@/utils/types"; +import { getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-create-group @@ -15,15 +16,8 @@ vi.mock("@/utils/gmail/message", () => ({ describe.runIf(isAiTest)("aiGenerateGroupItems", () => { it("should generate group items based on user prompt", async () => { - const user = { - email: "user@test.com", - aiProvider: null, - aiModel: null, - aiApiKey: null, - }; - + const user = getUser(); const gmail = {} as gmail_v1.Gmail; - const accessToken = "fake-access-token"; const group = { name: "Work Emails", prompt: From 00b93c3d2fcdb2f31cf76e780c63a7b354013929 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 14:03:58 +0300 Subject: [PATCH 08/21] put back db fields we're deprecating to avoid deletion --- apps/web/prisma/schema.prisma | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index a535112316..22feac7b92 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -53,33 +53,33 @@ model User { sessions Session[] // additional fields - // about String? - // signature String? - // watchEmailsExpirationDate DateTime? - // lastSyncedHistoryId String? + about String? // deprecated + signature String? // deprecated + watchEmailsExpirationDate DateTime? // deprecated + lastSyncedHistoryId String? // deprecated completedOnboardingAt DateTime? // questions about the user. e.g. their role completedAppOnboardingAt DateTime? // how to use the app onboardingAnswers Json? - // behaviorProfile Json? + behaviorProfile Json? // deprecated lastLogin DateTime? utms Json? errorMessages Json? // eg. user set incorrect AI API key // settings - // aiProvider String? - // aiModel String? - // aiApiKey String? - // statsEmailFrequency Frequency @default(WEEKLY) - // summaryEmailFrequency Frequency @default(WEEKLY) - // lastSummaryEmailAt DateTime? - // coldEmailBlocker ColdEmailSetting? - // coldEmailPrompt String? - // rulesPrompt String? - // webhookSecret String? - // outboundReplyTracking Boolean @default(false) + aiProvider String? // deprecated + aiModel String? // deprecated + aiApiKey String? // deprecated + statsEmailFrequency Frequency @default(WEEKLY) // deprecated + summaryEmailFrequency Frequency @default(WEEKLY) // deprecated + lastSummaryEmailAt DateTime? // deprecated + coldEmailBlocker ColdEmailSetting? // deprecated + coldEmailPrompt String? // deprecated + rulesPrompt String? // deprecated + webhookSecret String? // deprecated + outboundReplyTracking Boolean @default(false) // deprecated // categorization - // autoCategorizeSenders Boolean @default(false) + autoCategorizeSenders Boolean @default(false) // deprecated // premium can be shared among multiple users premiumId String? @@ -104,7 +104,7 @@ model User { knowledges Knowledge[] emailAccounts EmailAccount[] - // @@index([lastSummaryEmailAt]) + @@index([lastSummaryEmailAt]) } // Migrating over to the new settings model. Currently most settings are in the User model, but will be moved to this model in the future. From ce0eac4777ee97ae0e5ea373d988e85780a9a1a9 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 14:04:25 +0300 Subject: [PATCH 09/21] format --- apps/web/prisma/schema.prisma | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 22feac7b92..4a771a7ff7 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -56,27 +56,27 @@ model User { about String? // deprecated signature String? // deprecated watchEmailsExpirationDate DateTime? // deprecated - lastSyncedHistoryId String? // deprecated - completedOnboardingAt DateTime? // questions about the user. e.g. their role - completedAppOnboardingAt DateTime? // how to use the app - onboardingAnswers Json? - behaviorProfile Json? // deprecated - lastLogin DateTime? - utms Json? - errorMessages Json? // eg. user set incorrect AI API key + lastSyncedHistoryId String? // deprecated + completedOnboardingAt DateTime? // questions about the user. e.g. their role + completedAppOnboardingAt DateTime? // how to use the app + onboardingAnswers Json? + behaviorProfile Json? // deprecated + lastLogin DateTime? + utms Json? + errorMessages Json? // eg. user set incorrect AI API key // settings aiProvider String? // deprecated aiModel String? // deprecated aiApiKey String? // deprecated - statsEmailFrequency Frequency @default(WEEKLY) // deprecated - summaryEmailFrequency Frequency @default(WEEKLY) // deprecated + statsEmailFrequency Frequency @default(WEEKLY) // deprecated + summaryEmailFrequency Frequency @default(WEEKLY) // deprecated lastSummaryEmailAt DateTime? // deprecated coldEmailBlocker ColdEmailSetting? // deprecated coldEmailPrompt String? // deprecated rulesPrompt String? // deprecated webhookSecret String? // deprecated - outboundReplyTracking Boolean @default(false) // deprecated + outboundReplyTracking Boolean @default(false) // deprecated // categorization autoCategorizeSenders Boolean @default(false) // deprecated From d6caf73be4147042f6003cb6d74eca248daf5306 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 14:53:41 +0300 Subject: [PATCH 10/21] migration --- .../migration.sql | 140 ++++++++++++++++++ apps/web/prisma/schema.prisma | 3 +- 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql diff --git a/apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql b/apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql new file mode 100644 index 0000000000..08836236cc --- /dev/null +++ b/apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql @@ -0,0 +1,140 @@ +/* + Warnings: + + - You are about to drop the column `emailAccountId` on the `Account` table. All the data in the column will be lost. + - The primary key for the `EmailAccount` table will be changed. If it partially fails, the table could be left without primary key constraint. + - A unique constraint covering the columns `[email]` on the table `Account` will be added. If there are existing duplicate values, this will fail. + - Added the required column `email` to the `CleanupJob` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Account" DROP CONSTRAINT "Account_emailAccountId_fkey"; + +-- DropIndex +DROP INDEX "Account_emailAccountId_key"; + +-- DropIndex +DROP INDEX "EmailAccount_email_key"; + +-- AlterTable +ALTER TABLE "Account" DROP COLUMN "emailAccountId", +ADD COLUMN "email" TEXT; + +-- AlterTable +ALTER TABLE "CleanupJob" ADD COLUMN "email" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "EmailAccount" DROP CONSTRAINT "EmailAccount_pkey", +ADD COLUMN "about" TEXT, +ADD COLUMN "aiApiKey" TEXT, +ADD COLUMN "aiModel" TEXT, +ADD COLUMN "aiProvider" TEXT, +ADD COLUMN "autoCategorizeSenders" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "behaviorProfile" JSONB, +ADD COLUMN "coldEmailBlocker" "ColdEmailSetting", +ADD COLUMN "coldEmailPrompt" TEXT, +ADD COLUMN "lastSummaryEmailAt" TIMESTAMP(3), +ADD COLUMN "lastSyncedHistoryId" TEXT, +ADD COLUMN "outboundReplyTracking" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "rulesPrompt" TEXT, +ADD COLUMN "signature" TEXT, +ADD COLUMN "statsEmailFrequency" "Frequency" NOT NULL DEFAULT 'WEEKLY', +ADD COLUMN "summaryEmailFrequency" "Frequency" NOT NULL DEFAULT 'WEEKLY', +ADD COLUMN "watchEmailsExpirationDate" TIMESTAMP(3), +ADD COLUMN "webhookSecret" TEXT, +ADD CONSTRAINT "EmailAccount_pkey" PRIMARY KEY ("email"); + +-- Drop the deprecated id column BEFORE the data migration insert +ALTER TABLE "EmailAccount" DROP COLUMN "id"; + +-- CreateIndex +CREATE UNIQUE INDEX "Account_email_key" ON "Account"("email"); + +-- CreateIndex +CREATE INDEX "EmailAccount_lastSummaryEmailAt_idx" ON "EmailAccount"("lastSummaryEmailAt"); + +-- AddForeignKey +ALTER TABLE "Account" ADD CONSTRAINT "Account_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailAccount"("email") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CleanupJob" ADD CONSTRAINT "CleanupJob_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailAccount"("email") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Data migration: Ensure every User with an Account has a corresponding EmailAccount +-- and populate it with settings from the User model. + +-- Step 1: Create EmailAccount entries for existing Users linked to an Account +INSERT INTO "EmailAccount" ( + "email", "userId", "accountId", + "about", "signature", "watchEmailsExpirationDate", + "lastSyncedHistoryId", "behaviorProfile", "aiProvider", "aiModel", "aiApiKey", + "statsEmailFrequency", "summaryEmailFrequency", "lastSummaryEmailAt", "coldEmailBlocker", + "coldEmailPrompt", "rulesPrompt", "webhookSecret", "outboundReplyTracking", "autoCategorizeSenders", + "createdAt", "updatedAt" +) +SELECT + u.email, + u.id AS userId, + a.id AS accountId, + u.about, + u.signature, + u."watchEmailsExpirationDate", + u."lastSyncedHistoryId", + u."behaviorProfile", + u."aiProvider", + u."aiModel", + u."aiApiKey", + u."statsEmailFrequency", + u."summaryEmailFrequency", + u."lastSummaryEmailAt", + u."coldEmailBlocker", + u."coldEmailPrompt", + u."rulesPrompt", + u."webhookSecret", + u."outboundReplyTracking", + u."autoCategorizeSenders", + NOW(), -- Set creation timestamp + NOW() -- Set updated timestamp +FROM "User" u +JOIN "Account" a ON u.id = a."userId" -- Join ensures we only process users with accounts +WHERE u.email IS NOT NULL AND u.email <> '' -- Ensure the user has a valid email +ON CONFLICT ("email") DO UPDATE SET + -- If an EmailAccount with this email already exists, update its fields + -- This handles cases where the migration might be run multiple times or if some data exists partially + "userId" = EXCLUDED."userId", + "accountId" = EXCLUDED."accountId", + "about" = COALESCE(EXCLUDED.about, "EmailAccount".about), + "signature" = COALESCE(EXCLUDED.signature, "EmailAccount".signature), + "watchEmailsExpirationDate" = COALESCE(EXCLUDED."watchEmailsExpirationDate", "EmailAccount"."watchEmailsExpirationDate"), + "lastSyncedHistoryId" = COALESCE(EXCLUDED."lastSyncedHistoryId", "EmailAccount"."lastSyncedHistoryId"), + "behaviorProfile" = COALESCE(EXCLUDED."behaviorProfile", "EmailAccount"."behaviorProfile"), + "aiProvider" = COALESCE(EXCLUDED."aiProvider", "EmailAccount"."aiProvider"), + "aiModel" = COALESCE(EXCLUDED."aiModel", "EmailAccount"."aiModel"), + "aiApiKey" = COALESCE(EXCLUDED."aiApiKey", "EmailAccount"."aiApiKey"), + "statsEmailFrequency" = EXCLUDED."statsEmailFrequency", -- Non-nullable fields can be directly updated + "summaryEmailFrequency" = EXCLUDED."summaryEmailFrequency", + "lastSummaryEmailAt" = COALESCE(EXCLUDED."lastSummaryEmailAt", "EmailAccount"."lastSummaryEmailAt"), + "coldEmailBlocker" = EXCLUDED."coldEmailBlocker", -- Enum can be updated directly (nullable) + "coldEmailPrompt" = COALESCE(EXCLUDED."coldEmailPrompt", "EmailAccount"."coldEmailPrompt"), + "rulesPrompt" = COALESCE(EXCLUDED."rulesPrompt", "EmailAccount"."rulesPrompt"), + "webhookSecret" = COALESCE(EXCLUDED."webhookSecret", "EmailAccount"."webhookSecret"), + "outboundReplyTracking" = EXCLUDED."outboundReplyTracking", -- Non-nullable boolean + "autoCategorizeSenders" = EXCLUDED."autoCategorizeSenders", -- Non-nullable boolean + "updatedAt" = NOW(); -- Update the timestamp + +-- Step 2: Update the Account table to link to EmailAccount via email +-- This assumes the previous ALTER TABLE added the 'email' column +UPDATE "Account" acc +SET email = u.email +FROM "User" u +WHERE acc."userId" = u.id AND u.email IS NOT NULL AND u.email <> ''; + +-- Step 3: Update the CleanupJob table to link to EmailAccount via email +-- This assumes the previous ALTER TABLE added the 'email' column +UPDATE "CleanupJob" cj +SET email = u.email +FROM "User" u +WHERE cj."userId" = u.id AND u.email IS NOT NULL AND u.email <> ''; + +-- Note: The corresponding foreign key constraints (`Account_email_fkey`, `CleanupJob_email_fkey`) +-- should already be added by the schema migration part above this data migration script. +-- Also, the old `Account.emailAccountId` column should have been dropped. diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 4a771a7ff7..675979b747 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -109,8 +109,7 @@ model User { // Migrating over to the new settings model. Currently most settings are in the User model, but will be moved to this model in the future. model EmailAccount { - // id String @id @default(cuid()) - email String @unique + email String @id createdAt DateTime @default(now()) updatedAt DateTime @updatedAt writingStyle String? From 12a603d392ef34a5ec3e2303556eb7d085296c1e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 15:26:39 +0300 Subject: [PATCH 11/21] fix not logged in error --- apps/web/app/(app)/premium/Pricing.tsx | 4 +- .../(app)/settings/MultiAccountSection.tsx | 20 +++------- apps/web/app/(app)/usage/usage.tsx | 6 +-- apps/web/components/PremiumAlert.tsx | 5 ++- apps/web/hooks/useUser.ts | 2 +- apps/web/utils/auth.ts | 39 ++++++++++++++++++- 6 files changed, 53 insertions(+), 23 deletions(-) diff --git a/apps/web/app/(app)/premium/Pricing.tsx b/apps/web/app/(app)/premium/Pricing.tsx index 3ab309dee9..6ed1918c99 100644 --- a/apps/web/app/(app)/premium/Pricing.tsx +++ b/apps/web/app/(app)/premium/Pricing.tsx @@ -36,7 +36,7 @@ export function Pricing(props: { header?: React.ReactNode; showSkipUpgrade?: boolean; }) { - const { isPremium, data, isLoading, error } = usePremium(); + const { isPremium, premium, isLoading, error } = usePremium(); const session = useSession(); const defaultFrequency = usePricingFrequencyDefault(); @@ -45,7 +45,7 @@ export function Pricing(props: { ); const affiliateCode = useAffiliateCode(); - const premiumTier = getUserTier(data?.user.premium); + const premiumTier = getUserTier(premium); const skipVariant = useSkipUpgrade(); diff --git a/apps/web/app/(app)/settings/MultiAccountSection.tsx b/apps/web/app/(app)/settings/MultiAccountSection.tsx index 63db3a3a49..fce41c7efb 100644 --- a/apps/web/app/(app)/settings/MultiAccountSection.tsx +++ b/apps/web/app/(app)/settings/MultiAccountSection.tsx @@ -38,12 +38,12 @@ export function MultiAccountSection() { ); const { isPremium, - data: dataPremium, + premium, isLoading: isLoadingPremium, error: errorPremium, } = usePremium(); - const premiumTier = getUserTier(dataPremium?.user.premium); + const premiumTier = getUserTier(premium); const { openModal, PremiumModal } = usePremiumModal(); @@ -82,9 +82,7 @@ export function MultiAccountSection() { {premiumTier && ( )} @@ -92,15 +90,9 @@ export function MultiAccountSection() {
diff --git a/apps/web/app/(app)/usage/usage.tsx b/apps/web/app/(app)/usage/usage.tsx index 35b9f90840..fb76da7511 100644 --- a/apps/web/app/(app)/usage/usage.tsx +++ b/apps/web/app/(app)/usage/usage.tsx @@ -14,7 +14,7 @@ export function Usage(props: { openaiTokensUsed: number; } | null; }) { - const { data, isLoading, error } = usePremium(); + const { premium, isLoading, error } = usePremium(); return ( @@ -22,10 +22,10 @@ export function Usage(props: { stats={[ { name: "Unsubscribe Credits", - value: isPremium(data?.user.premium?.lemonSqueezyRenewsAt || null) + value: isPremium(premium?.lemonSqueezyRenewsAt || null) ? "Unlimited" : formatStat( - data?.user.premium?.unsubscribeCredits ?? + premium?.unsubscribeCredits ?? env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS, ), subvalue: "credits", diff --git a/apps/web/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index fe56b57a2f..b3f527c7f5 100644 --- a/apps/web/components/PremiumAlert.tsx +++ b/apps/web/components/PremiumAlert.tsx @@ -20,8 +20,8 @@ export function usePremium() { const swrResponse = useUser(); const { data } = swrResponse; - const premium = data?.user.premium; - const aiApiKey = data?.aiApiKey; + const premium = data && "user" in data ? data.user.premium : null; + const aiApiKey = data && "aiApiKey" in data ? data.aiApiKey : null; const isUserPremium = !!(premium && isPremium(premium.lemonSqueezyRenewsAt)); @@ -32,6 +32,7 @@ export function usePremium() { return { ...swrResponse, + premium, isPremium: isUserPremium, hasUnsubscribeAccess: isUserPremium || diff --git a/apps/web/hooks/useUser.ts b/apps/web/hooks/useUser.ts index f91c277c61..48cf591314 100644 --- a/apps/web/hooks/useUser.ts +++ b/apps/web/hooks/useUser.ts @@ -2,5 +2,5 @@ import useSWR from "swr"; import type { UserResponse } from "@/app/api/user/me/route"; export function useUser() { - return useSWR("/api/user/me"); + return useSWR("/api/user/me"); } diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 90807ffab0..827b66eff4 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -1,6 +1,7 @@ // based on: https://github.com/vercel/platforms/blob/main/lib/auth.ts import { PrismaAdapter } from "@auth/prisma-adapter"; import type { NextAuthConfig, DefaultSession, Account } from "next-auth"; +import type { AdapterAccount } from "@auth/core/adapters"; import type { JWT } from "@auth/core/jwt"; import GoogleProvider from "next-auth/providers/google"; import { createContact as createLoopsContact } from "@inboxzero/loops"; @@ -45,7 +46,43 @@ export const getAuthOptions: (options?: { }, }), ], - adapter: PrismaAdapter(prisma), + adapter: { + ...PrismaAdapter(prisma), + linkAccount: async (account: AdapterAccount): Promise => { + try { + // --- Step 1: Fetch the user to get their email --- + const user = await prisma.user.findUniqueOrThrow({ + where: { id: account.userId }, + select: { email: true }, + }); + + // --- Step 2: Create the Account record --- + const createdAccount = await prisma.account.create({ + data: { + ...account, + email: user.email, + }, + select: { id: true }, + }); + + // --- Step 3: Create the corresponding EmailAccount record --- + await prisma.emailAccount.create({ + data: { + email: user.email, + userId: account.userId, + accountId: createdAccount.id, + }, + }); + } catch (error) { + logger.error("Error linking account", { + userId: account.userId, + error, + }); + captureException(error, { extra: { userId: account.userId } }); + throw error; + } + }, + }, session: { strategy: "jwt" }, // based on: https://authjs.dev/guides/basics/refresh-token-rotation // and: https://github.com/nextauthjs/next-auth-refresh-token-example/blob/main/pages/api/auth/%5B...nextauth%5D.js From b1635f6217cd962af444d0677304ced4523f29c5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:05:54 +0300 Subject: [PATCH 12/21] fix errors --- apps/web/app/(app)/license/page.tsx | 6 +-- apps/web/hooks/useUser.ts | 4 +- apps/web/utils/swr.ts | 57 +++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 apps/web/utils/swr.ts diff --git a/apps/web/app/(app)/license/page.tsx b/apps/web/app/(app)/license/page.tsx index 98730648ae..b5d00ca636 100644 --- a/apps/web/app/(app)/license/page.tsx +++ b/apps/web/app/(app)/license/page.tsx @@ -8,7 +8,7 @@ import { TopSection } from "@/components/TopSection"; import { activateLicenseKeyAction } from "@/utils/actions/premium"; import { AlertBasic } from "@/components/Alert"; import { handleActionResult } from "@/utils/server-action"; -import { useUser } from "@/hooks/useUser"; +import { usePremium } from "@/components/PremiumAlert"; type Inputs = { licenseKey: string }; @@ -18,14 +18,14 @@ export default function LicensePage(props: { const searchParams = use(props.searchParams); const licenseKey = searchParams["license-key"]; - const { data } = useUser(); + const { premium } = usePremium(); return (
- {data?.user.premium?.lemonLicenseKey && ( + {premium?.lemonLicenseKey && ( ("/api/user/me"); + const swrResult = useSWR("/api/user/me"); + return processSWRResponse(swrResult); } diff --git a/apps/web/utils/swr.ts b/apps/web/utils/swr.ts new file mode 100644 index 0000000000..9f274aa823 --- /dev/null +++ b/apps/web/utils/swr.ts @@ -0,0 +1,57 @@ +import type { SWRResponse } from "swr"; + +// Define the standard error shape we want to normalize to +type NormalizedError = { error: string }; + +/** + * Processes the result of an SWR hook, normalizing errors. + * Assumes the API might return an object like { error: string } instead of data on failure. + * + * @param swrResult The raw result from the useSWR hook. + * @returns SWRResponse with data as TData | null and error as NormalizedError | undefined. + */ +export function processSWRResponse< + TData, + TApiError extends { error: string } = { error: string }, // Assume API error shape + TSWRError = Error, // Assume SWR error type +>( + swrResult: SWRResponse, +): SWRResponse { + const swrError = swrResult.error as TSWRError | undefined; // Cast for type checking + const data = swrResult.data as TData | TApiError | undefined; // Cast for type checking + + // Handle SWR hook error + if (swrError instanceof Error) { + return { + ...swrResult, + data: null, + error: { error: swrError.message }, + } as SWRResponse; + } + // Handle potential non-Error SWR errors (less common) + if (swrError) { + return { + ...swrResult, + data: null, + error: { error: String(swrError) }, // Convert non-Error to string + } as SWRResponse; + } + + + // Handle API error returned within data + if (data && typeof data === 'object' && 'error' in data && typeof data.error === 'string') { + return { + ...swrResult, + data: null, + error: { error: data.error }, + } as SWRResponse; + } + + // No error found, return the data (might be null/undefined during loading) + // Cast data to expected type, filtering out the TApiError possibility + return { + ...swrResult, + data: data as TData | null, // SWR handles undefined during load + error: undefined, + } as SWRResponse; +} \ No newline at end of file From 8441aad3edd2e238ae6b66c4788c65c82564f76a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:21:00 +0300 Subject: [PATCH 13/21] fix schema --- .../migration.sql | 14 +++------- apps/web/prisma/schema.prisma | 7 +++-- apps/web/utils/auth.ts | 27 +++++++------------ 3 files changed, 16 insertions(+), 32 deletions(-) rename apps/web/prisma/migrations/{20250420112535_email_account_settings => 20250420131728_email_account_settings}/migration.sql (91%) diff --git a/apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql similarity index 91% rename from apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql rename to apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql index 08836236cc..fc6a210041 100644 --- a/apps/web/prisma/migrations/20250420112535_email_account_settings/migration.sql +++ b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql @@ -3,7 +3,7 @@ - You are about to drop the column `emailAccountId` on the `Account` table. All the data in the column will be lost. - The primary key for the `EmailAccount` table will be changed. If it partially fails, the table could be left without primary key constraint. - - A unique constraint covering the columns `[email]` on the table `Account` will be added. If there are existing duplicate values, this will fail. + - You are about to drop the column `id` on the `EmailAccount` table. All the data in the column will be lost. - Added the required column `email` to the `CleanupJob` table without a default value. This is not possible if the table is not empty. */ @@ -17,14 +17,14 @@ DROP INDEX "Account_emailAccountId_key"; DROP INDEX "EmailAccount_email_key"; -- AlterTable -ALTER TABLE "Account" DROP COLUMN "emailAccountId", -ADD COLUMN "email" TEXT; +ALTER TABLE "Account" DROP COLUMN "emailAccountId"; -- AlterTable ALTER TABLE "CleanupJob" ADD COLUMN "email" TEXT NOT NULL; -- AlterTable ALTER TABLE "EmailAccount" DROP CONSTRAINT "EmailAccount_pkey", +DROP COLUMN "id", ADD COLUMN "about" TEXT, ADD COLUMN "aiApiKey" TEXT, ADD COLUMN "aiModel" TEXT, @@ -44,17 +44,11 @@ ADD COLUMN "watchEmailsExpirationDate" TIMESTAMP(3), ADD COLUMN "webhookSecret" TEXT, ADD CONSTRAINT "EmailAccount_pkey" PRIMARY KEY ("email"); --- Drop the deprecated id column BEFORE the data migration insert -ALTER TABLE "EmailAccount" DROP COLUMN "id"; - --- CreateIndex -CREATE UNIQUE INDEX "Account_email_key" ON "Account"("email"); - -- CreateIndex CREATE INDEX "EmailAccount_lastSummaryEmailAt_idx" ON "EmailAccount"("lastSummaryEmailAt"); -- AddForeignKey -ALTER TABLE "Account" ADD CONSTRAINT "Account_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailAccount"("email") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "EmailAccount" ADD CONSTRAINT "EmailAccount_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "CleanupJob" ADD CONSTRAINT "CleanupJob_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailAccount"("email") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 675979b747..aea8fd406b 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -26,8 +26,7 @@ model Account { session_state String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) - email String? @unique - emailAccount EmailAccount? @relation(fields: [email], references: [email]) + emailAccount EmailAccount? @@unique([provider, providerAccountId]) } @@ -109,7 +108,7 @@ model User { // Migrating over to the new settings model. Currently most settings are in the User model, but will be moved to this model in the future. model EmailAccount { - email String @id + email String @id createdAt DateTime @default(now()) updatedAt DateTime @updatedAt writingStyle String? @@ -140,7 +139,7 @@ model EmailAccount { userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) accountId String @unique - account Account? + account Account? @relation(fields: [accountId], references: [id]) cleanupJobs CleanupJob[] diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index 827b66eff4..af6ab40ec3 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -48,37 +48,28 @@ export const getAuthOptions: (options?: { ], adapter: { ...PrismaAdapter(prisma), - linkAccount: async (account: AdapterAccount): Promise => { + linkAccount: async (data: AdapterAccount): Promise => { try { - // --- Step 1: Fetch the user to get their email --- - const user = await prisma.user.findUniqueOrThrow({ - where: { id: account.userId }, - select: { email: true }, - }); - - // --- Step 2: Create the Account record --- + // --- Step 1: Create the Account record --- const createdAccount = await prisma.account.create({ - data: { - ...account, - email: user.email, - }, - select: { id: true }, + data, + select: { id: true, user: { select: { email: true } } }, }); - // --- Step 3: Create the corresponding EmailAccount record --- + // --- Step 2: Create the corresponding EmailAccount record --- await prisma.emailAccount.create({ data: { - email: user.email, - userId: account.userId, + email: createdAccount.user.email, + userId: data.userId, accountId: createdAccount.id, }, }); } catch (error) { logger.error("Error linking account", { - userId: account.userId, + userId: data.userId, error, }); - captureException(error, { extra: { userId: account.userId } }); + captureException(error, { extra: { userId: data.userId } }); throw error; } }, From e71219f7c45e946fd64b908de1c9e02ccc3eea48 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 16:36:04 +0300 Subject: [PATCH 14/21] fix migration - non unique account --- .../migration.sql | 13 +------------ apps/web/prisma/schema.prisma | 6 +++--- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql index fc6a210041..38f6b06fda 100644 --- a/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql +++ b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql @@ -115,20 +115,9 @@ ON CONFLICT ("email") DO UPDATE SET "autoCategorizeSenders" = EXCLUDED."autoCategorizeSenders", -- Non-nullable boolean "updatedAt" = NOW(); -- Update the timestamp --- Step 2: Update the Account table to link to EmailAccount via email --- This assumes the previous ALTER TABLE added the 'email' column -UPDATE "Account" acc -SET email = u.email -FROM "User" u -WHERE acc."userId" = u.id AND u.email IS NOT NULL AND u.email <> ''; - --- Step 3: Update the CleanupJob table to link to EmailAccount via email +-- Step 2: Update the CleanupJob table to link to EmailAccount via email -- This assumes the previous ALTER TABLE added the 'email' column UPDATE "CleanupJob" cj SET email = u.email FROM "User" u WHERE cj."userId" = u.id AND u.email IS NOT NULL AND u.email <> ''; - --- Note: The corresponding foreign key constraints (`Account_email_fkey`, `CleanupJob_email_fkey`) --- should already be added by the schema migration part above this data migration script. --- Also, the old `Account.emailAccountId` column should have been dropped. diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index aea8fd406b..4e56680328 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -137,9 +137,9 @@ model EmailAccount { // ... userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - accountId String @unique - account Account? @relation(fields: [accountId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accountId String @unique + account Account @relation(fields: [accountId], references: [id]) cleanupJobs CleanupJob[] From 2ab194999e52e33b6a28f9ca990e78ad2b26f0f5 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 20:46:59 +0300 Subject: [PATCH 15/21] fix migration script --- .../migration.sql | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql index 38f6b06fda..acb771914f 100644 --- a/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql +++ b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql @@ -20,7 +20,8 @@ DROP INDEX "EmailAccount_email_key"; ALTER TABLE "Account" DROP COLUMN "emailAccountId"; -- AlterTable -ALTER TABLE "CleanupJob" ADD COLUMN "email" TEXT NOT NULL; +-- Step 1: Add the email column, allowing NULLs for now +ALTER TABLE "CleanupJob" ADD COLUMN "email" TEXT; -- AlterTable ALTER TABLE "EmailAccount" DROP CONSTRAINT "EmailAccount_pkey", @@ -50,9 +51,6 @@ CREATE INDEX "EmailAccount_lastSummaryEmailAt_idx" ON "EmailAccount"("lastSummar -- AddForeignKey ALTER TABLE "EmailAccount" ADD CONSTRAINT "EmailAccount_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT ON UPDATE CASCADE; --- AddForeignKey -ALTER TABLE "CleanupJob" ADD CONSTRAINT "CleanupJob_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailAccount"("email") ON DELETE CASCADE ON UPDATE CASCADE; - -- Data migration: Ensure every User with an Account has a corresponding EmailAccount -- and populate it with settings from the User model. @@ -116,8 +114,13 @@ ON CONFLICT ("email") DO UPDATE SET "updatedAt" = NOW(); -- Update the timestamp -- Step 2: Update the CleanupJob table to link to EmailAccount via email --- This assumes the previous ALTER TABLE added the 'email' column UPDATE "CleanupJob" cj SET email = u.email FROM "User" u WHERE cj."userId" = u.id AND u.email IS NOT NULL AND u.email <> ''; + +-- Step 3: Now that all rows have a non-null email, enforce the NOT NULL constraint +ALTER TABLE "CleanupJob" ALTER COLUMN "email" SET NOT NULL; + +-- Step 4: Add the foreign key constraint (moved from earlier) +ALTER TABLE "CleanupJob" ADD CONSTRAINT "CleanupJob_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailAccount"("email") ON DELETE CASCADE ON UPDATE CASCADE; From 3a3ac8957e1919f88bf54e1b3ca49960c16b738e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:08:51 +0300 Subject: [PATCH 16/21] fix bug --- .../ai/assistant/process-user-request.ts | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index e1828487b5..3b235443c9 100644 --- a/apps/web/utils/ai/assistant/process-user-request.ts +++ b/apps/web/utils/ai/assistant/process-user-request.ts @@ -595,12 +595,25 @@ ${senderCategory || "No category"} } // Update prompt file for modified rules - for (const rule of updatedRules.values()) { - // TODO: should current and updated be the same? + for (const updatedRule of updatedRules.values()) { + // Find the original rule state from the initial rules array + const originalRule = rules.find((r) => r.id === updatedRule.id); + + if (!originalRule) { + logger.error( + "Original rule not found when updating prompt file for modified rule", + { + ...loggerOptions, + updatedRuleId: updatedRule.id, + }, + ); + continue; // Skip if original rule not found (should not happen ideally) + } + await updatePromptFileOnRuleUpdated({ email: user.email, - currentRule: rule, - updatedRule: rule, + currentRule: originalRule, + updatedRule: updatedRule, }); } From cb1444efd6527b437f71c2b28b481fcd53024246 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:12:36 +0300 Subject: [PATCH 17/21] fix prompt --- apps/web/utils/rule/prompt-file.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/utils/rule/prompt-file.ts b/apps/web/utils/rule/prompt-file.ts index cee86b2c07..b5f8654c3c 100644 --- a/apps/web/utils/rule/prompt-file.ts +++ b/apps/web/utils/rule/prompt-file.ts @@ -95,10 +95,9 @@ async function appendRulePrompt({ select: { rulesPrompt: true }, }); - if (!emailAccount?.rulesPrompt) return; + const existingPrompt = emailAccount?.rulesPrompt ?? ""; - const updatedPrompt = - `${emailAccount.rulesPrompt || ""}\n\n* ${rulePrompt}.`.trim(); + const updatedPrompt = `${existingPrompt}\n\n* ${rulePrompt}.`.trim(); await prisma.emailAccount.update({ where: { email }, From 0b9b3a83f93ad8fde3f75707ff08a2dc8772588e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:15:30 +0300 Subject: [PATCH 18/21] await --- apps/web/app/api/google/watch/all/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/app/api/google/watch/all/route.ts b/apps/web/app/api/google/watch/all/route.ts index 6744e686a2..eb5f994f23 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -88,7 +88,7 @@ async function watchAllEmails() { emailAccount.watchEmailsExpirationDate && new Date(emailAccount.watchEmailsExpirationDate) < new Date() ) { - prisma.emailAccount.update({ + await prisma.emailAccount.update({ where: { email: emailAccount.email }, data: { watchEmailsExpirationDate: null }, }); From f5bf22f3348862081711760ddc3d80b2e82cb12a Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:19:28 +0300 Subject: [PATCH 19/21] upsert --- apps/web/utils/auth.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/utils/auth.ts b/apps/web/utils/auth.ts index af6ab40ec3..3f37a86ad1 100644 --- a/apps/web/utils/auth.ts +++ b/apps/web/utils/auth.ts @@ -57,8 +57,13 @@ export const getAuthOptions: (options?: { }); // --- Step 2: Create the corresponding EmailAccount record --- - await prisma.emailAccount.create({ - data: { + await prisma.emailAccount.upsert({ + where: { email: createdAccount.user.email }, + update: { + userId: data.userId, + accountId: createdAccount.id, + }, + create: { email: createdAccount.user.email, userId: data.userId, accountId: createdAccount.id, From dec71b07cc260d344c8ef1cd5ab8c43563703046 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:25:00 +0300 Subject: [PATCH 20/21] improve sort --- apps/web/app/api/google/watch/all/route.ts | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/apps/web/app/api/google/watch/all/route.ts b/apps/web/app/api/google/watch/all/route.ts index eb5f994f23..f5a0a829a3 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -45,29 +45,16 @@ async function watchAllEmails() { }, }, }, + orderBy: { + watchEmailsExpirationDate: { sort: "asc", nulls: "first" }, + }, }); logger.info("Watching emails for users", { count: emailAccounts.length, }); - const sortedEmailAccounts = emailAccounts.sort((a, b) => { - // Prioritize null dates first - if (!a.watchEmailsExpirationDate && b.watchEmailsExpirationDate) return -1; - if (a.watchEmailsExpirationDate && !b.watchEmailsExpirationDate) return 1; - - // If both have dates, sort by earliest date first - if (a.watchEmailsExpirationDate && b.watchEmailsExpirationDate) { - return ( - new Date(a.watchEmailsExpirationDate).getTime() - - new Date(b.watchEmailsExpirationDate).getTime() - ); - } - - return 0; - }); - - for (const emailAccount of sortedEmailAccounts) { + for (const emailAccount of emailAccounts) { try { logger.info("Watching emails for user", { email: emailAccount.email }); From a23e4df8676367e658ccc23de67efd161f9cca17 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:27:52 +0300 Subject: [PATCH 21/21] hash --- apps/web/app/api/clean/route.ts | 3 ++- apps/web/utils/actions/clean.ts | 3 ++- apps/web/utils/hash.ts | 4 ++++ apps/web/utils/upstash/categorize-senders.ts | 3 ++- apps/web/utils/upstash/index.ts | 1 - 5 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 apps/web/utils/hash.ts diff --git a/apps/web/app/api/clean/route.ts b/apps/web/app/api/clean/route.ts index 337497d6ea..fc82187a84 100644 --- a/apps/web/app/api/clean/route.ts +++ b/apps/web/app/api/clean/route.ts @@ -20,6 +20,7 @@ import { saveThread, updateThread } from "@/utils/redis/clean"; import { internalDateToDate } from "@/utils/date"; import { CleanAction } from "@prisma/client"; import type { ParsedMessage } from "@/utils/types"; +import { hash } from "@/utils/hash"; const logger = createScopedLogger("api/clean"); @@ -258,7 +259,7 @@ function getPublish({ await Promise.all([ publishToQstash("/api/clean/gmail", cleanGmailBody, { - key: `gmail-action-${email}`, + key: `gmail-action-${hash(email)}`, ratePerSecond: maxRatePerSecond, }), updateThread({ diff --git a/apps/web/utils/actions/clean.ts b/apps/web/utils/actions/clean.ts index 22e4e1a9c1..d0ccf1ee59 100644 --- a/apps/web/utils/actions/clean.ts +++ b/apps/web/utils/actions/clean.ts @@ -32,6 +32,7 @@ import prisma from "@/utils/prisma"; import { CleanAction } from "@prisma/client"; import { updateThread } from "@/utils/redis/clean"; import { getUnhandledCount } from "@/utils/assess"; +import { hash } from "@/utils/hash"; const logger = createScopedLogger("actions/clean"); @@ -175,7 +176,7 @@ export const cleanInboxAction = withActionInstrumentation( // api keys or a global queue // problem with a global queue is that if there's a backlog users will have to wait for others to finish first flowControl: { - key: `ai-clean-${email}`, + key: `ai-clean-${hash(email)}`, parallelism: 3, }, }; diff --git a/apps/web/utils/hash.ts b/apps/web/utils/hash.ts new file mode 100644 index 0000000000..329c842e4a --- /dev/null +++ b/apps/web/utils/hash.ts @@ -0,0 +1,4 @@ +import { createHash } from "node:crypto"; + +export const hash = (str: string) => + createHash("sha256").update(str).digest("hex").slice(0, 16); diff --git a/apps/web/utils/upstash/categorize-senders.ts b/apps/web/utils/upstash/categorize-senders.ts index a7c1c7db45..a775d19eea 100644 --- a/apps/web/utils/upstash/categorize-senders.ts +++ b/apps/web/utils/upstash/categorize-senders.ts @@ -2,6 +2,7 @@ import chunk from "lodash/chunk"; import { deleteQueue, listQueues, publishToQstashQueue } from "@/utils/upstash"; import { env } from "@/env"; import type { AiCategorizeSenders } from "@/app/api/user/categorize/senders/batch/handle-batch-validation"; +import { hash } from "@/utils/hash"; import { createScopedLogger } from "@/utils/logger"; const logger = createScopedLogger("upstash"); @@ -9,7 +10,7 @@ const logger = createScopedLogger("upstash"); const CATEGORIZE_SENDERS_PREFIX = "ai-categorize-senders"; const getCategorizeSendersQueueName = ({ email }: { email: string }) => - `${CATEGORIZE_SENDERS_PREFIX}-${email}`; + `${CATEGORIZE_SENDERS_PREFIX}-${hash(email)}`; /** * Publishes sender categorization tasks to QStash queue in batches diff --git a/apps/web/utils/upstash/index.ts b/apps/web/utils/upstash/index.ts index 0dbe705aae..12361796a0 100644 --- a/apps/web/utils/upstash/index.ts +++ b/apps/web/utils/upstash/index.ts @@ -1,7 +1,6 @@ import { Client, type FlowControl, type HeadersInit } from "@upstash/qstash"; import { env } from "@/env"; import { INTERNAL_API_KEY_HEADER } from "@/utils/internal-api"; -import { SafeError } from "@/utils/error"; import { sleep } from "@/utils/sleep"; import { createScopedLogger } from "@/utils/logger";