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..7b146482c3 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 @@ -197,7 +198,7 @@ function getRule( enabled: true, categoryFilterType: null, conditionalOperator: LogicalOperator.AND, - type: null, + systemType: null, }; } @@ -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..421c78c482 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 { getEmail, getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-choose-rule @@ -347,26 +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, - }; -} - -function getUser() { - return { - aiModel: null, - aiProvider: null, - email: "user@test.com", - aiApiKey: null, - about: null, - }; -} 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: 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-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-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-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-find-snippets.test.ts b/apps/web/__tests__/ai-find-snippets.test.ts index edaaa311f1..fbc83c5267 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 { getEmail, getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-find-snippets const isAiTest = process.env.RUN_AI_TESTS === "true"; @@ -63,30 +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 }), - }; -} - -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..04cd8b08fe 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 @@ -445,6 +446,7 @@ function getRule(rule: Partial): RuleWithRelations { enabled: true, createdAt: new Date(), updatedAt: new Date(), + systemType: null, ...rule, }; } @@ -477,17 +479,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..2997e3d7ca 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 { getEmail, getUser } from "@/__tests__/helpers"; // pnpm test-ai ai-rule-fix @@ -161,29 +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 }), - }; -} - -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..4c12a197a9 --- /dev/null +++ b/apps/web/__tests__/helpers.ts @@ -0,0 +1,29 @@ +import type { EmailForLLM } from "@/utils/types"; + +export function getUser() { + return { + userId: "user1", + email: "user@test.com", + aiModel: null, + aiProvider: null, + aiApiKey: null, + 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 }), + }; +} 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/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..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?.premium?.lemonLicenseKey && ( + {premium?.lemonLicenseKey && ( )} @@ -92,13 +90,9 @@ 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..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?.premium?.lemonSqueezyRenewsAt || null) + value: isPremium(premium?.lemonSqueezyRenewsAt || null) ? "Unlimited" : formatStat( - data?.premium?.unsubscribeCredits ?? + 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..fc82187a84 100644 --- a/apps/web/app/api/clean/route.ts +++ b/apps/web/app/api/clean/route.ts @@ -20,11 +20,12 @@ 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"); const cleanThreadBody = z.object({ - userId: z.string(), + email: z.string(), threadId: z.string(), markedDoneLabelId: z.string(), processedLabelId: z.string(), @@ -44,7 +45,7 @@ const cleanThreadBody = z.object({ export type CleanThreadBody = z.infer; async function cleanThread({ - userId, + email, threadId, markedDoneLabelId, processedLabelId, @@ -58,7 +59,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 +75,7 @@ async function cleanThread({ const messages = await getThreadMessages(threadId, gmail); logger.info("Fetched messages", { - userId, + email, threadId, messageCount: messages.length, }); @@ -82,7 +83,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 +93,7 @@ async function cleanThread({ }); const publish = getPublish({ - userId, + email, threadId, markedDoneLabelId, processedLabelId, @@ -214,14 +215,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 +240,7 @@ function getPublish({ const maxRatePerSecond = Math.ceil(12 / actionCount); const cleanGmailBody: CleanGmailBody = { - userId, + email, threadId, markDone, action, @@ -250,7 +251,7 @@ function getPublish({ }; logger.info("Publishing to Qstash", { - userId, + email, threadId, maxRatePerSecond, markDone, @@ -258,17 +259,22 @@ function getPublish({ await Promise.all([ publishToQstash("/api/clean/gmail", cleanGmailBody, { - key: `gmail-action-${userId}`, + key: `gmail-action-${hash(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..f5a0a829a3 100644 --- a/apps/web/app/api/google/watch/all/route.ts +++ b/apps/web/app/api/google/watch/all/route.ts @@ -14,77 +14,69 @@ 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, }, }, }, }, }, + orderBy: { + watchEmailsExpirationDate: { sort: "asc", nulls: "first" }, + }, }); - 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() - ); - } - - return 0; - }); - - logger.info("Watching emails for users", { count: users.length }); + logger.info("Watching emails for users", { + count: emailAccounts.length, + }); - for (const user of users) { + for (const emailAccount of emailAccounts) { 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 }, + await prisma.emailAccount.update({ + where: { email: emailAccount.email }, data: { watchEmailsExpirationDate: null }, }); } @@ -102,30 +94,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/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/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/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/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/components/PremiumAlert.tsx b/apps/web/components/PremiumAlert.tsx index ab43ade74f..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?.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..4ae62109a2 100644 --- a/apps/web/hooks/useUser.ts +++ b/apps/web/hooks/useUser.ts @@ -1,6 +1,8 @@ import useSWR from "swr"; import type { UserResponse } from "@/app/api/user/me/route"; +import { processSWRResponse } from "@/utils/swr"; // Import the generic helper export function useUser() { - return useSWR("/api/user/me"); + const swrResult = useSWR("/api/user/me"); + return processSWRResponse(swrResult); } diff --git a/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql new file mode 100644 index 0000000000..acb771914f --- /dev/null +++ b/apps/web/prisma/migrations/20250420131728_email_account_settings/migration.sql @@ -0,0 +1,126 @@ +/* + 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. + - 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. + +*/ +-- 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"; + +-- AlterTable +-- 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", +DROP COLUMN "id", +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"); + +-- CreateIndex +CREATE INDEX "EmailAccount_lastSummaryEmailAt_idx" ON "EmailAccount"("lastSummaryEmailAt"); + +-- AddForeignKey +ALTER TABLE "EmailAccount" ADD CONSTRAINT "EmailAccount_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE RESTRICT 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 CleanupJob table to link to EmailAccount via email +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; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 4478550ed6..4e56680328 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -25,9 +25,8 @@ 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) + emailAccount EmailAccount? @@unique([provider, providerAccountId]) } @@ -53,33 +52,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? @@ -109,21 +108,42 @@ 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 @id 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? // ... userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - accountId String @unique - account Account? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + accountId String @unique + account Account @relation(fields: [accountId], references: [id]) + + cleanupJobs CleanupJob[] + + @@index([lastSummaryEmailAt]) } model Premium { @@ -447,6 +467,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..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"); @@ -39,8 +40,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 +68,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 +139,7 @@ export const cleanInboxAction = withActionInstrumentation( }); logger.info("Fetched threads", { - userId, + email, threadCount: threads.length, nextPageToken, }); @@ -149,7 +151,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 +162,7 @@ export const cleanInboxAction = withActionInstrumentation( return { url, body: { - userId, + email, threadId: thread.id, markedDoneLabelId, processedLabelId, @@ -174,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-${userId}`, + key: `ai-clean-${hash(email)}`, parallelism: 3, }, }; @@ -205,8 +207,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 +245,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 +276,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 +308,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/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 }); }); }; diff --git a/apps/web/utils/ai/assistant/process-user-request.ts b/apps/web/utils/ai/assistant/process-user-request.ts index 58a0dcb9e5..3b235443c9 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,30 @@ ${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); + 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: originalRule, + updatedRule: updatedRule, + }); } posthogCaptureEvent(user.email, "AI Assistant Process Completed", { 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/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..cee0a155bf 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,17 @@ 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, + email: user.email, + }); logger.trace("Matching rule", { result }); @@ -63,7 +68,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 +80,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 +99,7 @@ async function executeMatchedRule( ? undefined : await saveExecutedRule( { - userId: user.id, + userId: user.userId, threadId: message.threadId, messageId: message.id, }, @@ -243,12 +248,17 @@ async function upsertExecutedRule({ } } -async function analyzeSenderPatternIfAiMatch( - isTest: boolean, - result: { rule?: Rule | null; matchReasons?: MatchReason[] }, - message: ParsedMessage, - user: Pick, -) { +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: user.id, + email, 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/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/auth.ts b/apps/web/utils/auth.ts index 90807ffab0..3f37a86ad1 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,39 @@ export const getAuthOptions: (options?: { }, }), ], - adapter: PrismaAdapter(prisma), + adapter: { + ...PrismaAdapter(prisma), + linkAccount: async (data: AdapterAccount): Promise => { + try { + // --- Step 1: Create the Account record --- + const createdAccount = await prisma.account.create({ + data, + select: { id: true, user: { select: { email: true } } }, + }); + + // --- Step 2: Create the corresponding EmailAccount record --- + 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, + }, + }); + } catch (error) { + logger.error("Error linking account", { + userId: data.userId, + error, + }); + captureException(error, { extra: { userId: data.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 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.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, 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/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/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/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/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..1344cf28cb 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({ + email: user.email, + 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, + 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/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..b5f8654c3c 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,34 @@ 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; + const existingPrompt = emailAccount?.rulesPrompt ?? ""; - const updatedPrompt = `${user.rulesPrompt || ""}\n\n* ${rulePrompt}.`.trim(); + const updatedPrompt = `${existingPrompt}\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/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 diff --git a/apps/web/utils/upstash/categorize-senders.ts b/apps/web/utils/upstash/categorize-senders.ts index 92f89f5885..a775d19eea 100644 --- a/apps/web/utils/upstash/categorize-senders.ts +++ b/apps/web/utils/upstash/categorize-senders.ts @@ -2,14 +2,15 @@ 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"); const CATEGORIZE_SENDERS_PREFIX = "ai-categorize-senders"; -const getCategorizeSendersQueueName = (userId: string) => - `${CATEGORIZE_SENDERS_PREFIX}-${userId}`; +const getCategorizeSendersQueueName = ({ email }: { email: string }) => + `${CATEGORIZE_SENDERS_PREFIX}-${hash(email)}`; /** * Publishes sender categorization tasks to QStash queue in batches @@ -25,7 +26,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 +42,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/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"; 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/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 }; } 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), }),