diff --git a/apps/web/app/api/watch/all/route.ts b/apps/web/app/api/watch/all/route.ts index 871f6427c9..8655c89469 100644 --- a/apps/web/app/api/watch/all/route.ts +++ b/apps/web/app/api/watch/all/route.ts @@ -1,12 +1,9 @@ import { NextResponse } from "next/server"; -import prisma from "@/utils/prisma"; -import { watchEmails } from "../controller"; -import { createEmailProvider } from "@/utils/email/provider"; import { hasCronSecret, hasPostCronSecret } from "@/utils/cron"; import { withError } from "@/utils/middleware"; import { captureException } from "@/utils/error"; -import { hasAiAccess } from "@/utils/premium"; import { createScopedLogger } from "@/utils/logger"; +import { ensureEmailAccountsWatched } from "@/utils/email/watch-manager"; const logger = createScopedLogger("api/watch/all"); @@ -14,126 +11,13 @@ export const dynamic = "force-dynamic"; export const maxDuration = 300; async function watchAllEmails() { - const emailAccounts = await prisma.emailAccount.findMany({ - where: { - user: { - premium: { - OR: [ - { lemonSqueezyRenewsAt: { gt: new Date() } }, - { stripeSubscriptionStatus: { in: ["active", "trialing"] } }, - ], - }, - }, - }, - select: { - id: true, - email: true, - watchEmailsExpirationDate: true, - watchEmailsSubscriptionId: true, - account: { - select: { - provider: true, - access_token: true, - refresh_token: true, - expires_at: true, - }, - }, - user: { - select: { - aiApiKey: true, - premium: { select: { tier: true } }, - }, - }, - }, - orderBy: { - watchEmailsExpirationDate: { sort: "asc", nulls: "first" }, - }, - }); - - logger.info("Watching email accounts", { count: emailAccounts.length }); - - for (const emailAccount of emailAccounts) { - try { - logger.info("Watching emails for account", { - emailAccountId: emailAccount.id, - email: emailAccount.email, - provider: emailAccount.account.provider, - }); - - const userHasAiAccess = hasAiAccess( - emailAccount.user.premium?.tier || null, - emailAccount.user.aiApiKey, - ); - - if (!userHasAiAccess) { - logger.info("User does not have access to AI or cold email", { - email: emailAccount.email, - }); - if ( - emailAccount.watchEmailsExpirationDate && - new Date(emailAccount.watchEmailsExpirationDate) < new Date() - ) { - await prisma.emailAccount.update({ - where: { email: emailAccount.email }, - data: { - watchEmailsExpirationDate: null, - watchEmailsSubscriptionId: null, - }, - }); - } - - continue; - } - - if ( - !emailAccount.account?.access_token || - !emailAccount.account?.refresh_token - ) { - logger.info("User has no access token or refresh token", { - email: emailAccount.email, - }); - continue; - } - - const provider = await createEmailProvider({ - emailAccountId: emailAccount.id, - provider: emailAccount.account.provider, - }); - - const result = await watchEmails({ - emailAccountId: emailAccount.id, - provider, - }); - - if (!result.success) { - logger.error("Failed to watch emails for account", { - emailAccountId: emailAccount.id, - email: emailAccount.email, - error: result.error, - }); - } - } catch (error) { - if (error instanceof Error) { - const warn = [ - "invalid_grant", - "Mail service not enabled", - "Insufficient Permission", - ]; - - if (warn.some((w) => error.message.includes(w))) { - logger.warn("Not watching emails for user", { - email: emailAccount.email, - error, - }); - continue; - } - } - - logger.error("Error for user", { email: emailAccount.email, error }); - } + try { + const results = await ensureEmailAccountsWatched({ userIds: null }); + return NextResponse.json({ success: true, results }); + } catch (error) { + logger.error("Failed to watch all emails", { error }); + throw error; } - - return NextResponse.json({ success: true }); } export const GET = withError(async (request) => { diff --git a/apps/web/app/api/watch/route.ts b/apps/web/app/api/watch/route.ts index a116eb02e3..3aac8b9b5d 100644 --- a/apps/web/app/api/watch/route.ts +++ b/apps/web/app/api/watch/route.ts @@ -1,97 +1,24 @@ import { NextResponse } from "next/server"; import { withAuth } from "@/utils/middleware"; -import { createScopedLogger } from "@/utils/logger"; import prisma from "@/utils/prisma"; -import { watchEmails } from "./controller"; -import { createEmailProvider } from "@/utils/email/provider"; +import { ensureEmailAccountsWatched } from "@/utils/email/watch-manager"; export const dynamic = "force-dynamic"; -const logger = createScopedLogger("api/watch"); - export const GET = withAuth(async (request) => { const userId = request.auth.userId; - const results = []; - - const emailAccounts = await prisma.emailAccount.findMany({ + const emailAccountCount = await prisma.emailAccount.count({ where: { userId }, - select: { - id: true, - account: { - select: { - provider: true, - access_token: true, - refresh_token: true, - expires_at: true, - }, - }, - }, }); - if (emailAccounts.length === 0) { + if (emailAccountCount === 0) { return NextResponse.json( { message: "No email accounts found for this user." }, { status: 404 }, ); } - for (const { id: emailAccountId, account } of emailAccounts) { - try { - // Check for missing tokens for Microsoft accounts - if (!account.access_token || !account.refresh_token) { - logger.warn("Missing tokens for account", { emailAccountId }); - results.push({ - emailAccountId, - status: "error", - message: "Missing authentication tokens.", - }); - continue; - } - - // Create email provider for this account - const provider = await createEmailProvider({ - emailAccountId, - provider: account.provider, - }); - - const result = await watchEmails({ - emailAccountId, - provider, - }); - - if (result.success) { - results.push({ - emailAccountId, - status: "success", - expirationDate: result.expirationDate, - }); - } else { - logger.error("Error watching inbox for account", { - emailAccountId, - provider: account.provider, - error: result.error, - }); - results.push({ - emailAccountId, - status: "error", - message: "Failed to set up watch for this account.", - errorDetails: result.error, - }); - } - } catch (error) { - logger.error("Exception while watching inbox for account", { - emailAccountId, - error, - }); - results.push({ - emailAccountId, - status: "error", - message: - "An unexpected error occurred while setting up watch for this account.", - errorDetails: error instanceof Error ? error.message : String(error), - }); - } - } + const results = await ensureEmailAccountsWatched({ userIds: [userId] }); return NextResponse.json({ results }); }); diff --git a/apps/web/ee/billing/stripe/sync-stripe.ts b/apps/web/ee/billing/stripe/sync-stripe.ts index 1ae3a9e33b..d1a7de6f41 100644 --- a/apps/web/ee/billing/stripe/sync-stripe.ts +++ b/apps/web/ee/billing/stripe/sync-stripe.ts @@ -1,3 +1,4 @@ +import { after } from "next/server"; import sumBy from "lodash/sumBy"; import prisma from "@/utils/prisma"; import { createScopedLogger } from "@/utils/logger"; @@ -5,6 +6,7 @@ import { getStripe } from "@/ee/billing/stripe"; import { getStripeSubscriptionTier } from "@/app/(app)/premium/config"; import { handleLoopsEvents } from "@/ee/billing/stripe/loops-events"; import { updateAccountSeatsForPremium } from "@/utils/premium/server"; +import { ensureEmailAccountsWatched } from "@/utils/email/watch-manager"; import type { Prisma } from "@prisma/client"; const logger = createScopedLogger("stripe/syncStripeDataToDb"); @@ -122,6 +124,7 @@ export async function syncStripeDataToDb({ pendingInvites: true, users: { select: { + id: true, email: true, _count: { select: { emailAccounts: true } }, }, @@ -141,6 +144,24 @@ export async function syncStripeDataToDb({ }); await syncSeats(updatedPremium); + + after(() => { + const userIds = updatedPremium.users.map((user) => user.id); + + const statusChanged = + currentPremium?.stripeSubscriptionStatus !== subscription.status; + const tierChanged = currentPremium?.tier !== tier; + + if (userIds.length && (!currentPremium || statusChanged || tierChanged)) { + ensureEmailAccountsWatched({ userIds }).catch((error) => { + logger.error("Failed to ensure email watches after Stripe sync", { + customerId, + userIds, + error, + }); + }); + } + }); } catch (error) { logger.error("Error syncing Stripe data to DB", { customerId, error }); throw error; diff --git a/apps/web/utils/auth.test.ts b/apps/web/utils/auth.test.ts index aa0a4b63cf..850144e687 100644 --- a/apps/web/utils/auth.test.ts +++ b/apps/web/utils/auth.test.ts @@ -5,6 +5,8 @@ import { captureException } from "@/utils/error"; import { handleReferralOnSignUp } from "@/utils/auth"; // Mock the dependencies +vi.mock("server-only", () => ({})); + vi.mock("next/headers", () => ({ cookies: vi.fn(), })); diff --git a/apps/web/utils/email/watch-manager.ts b/apps/web/utils/email/watch-manager.ts new file mode 100644 index 0000000000..ff5cda4690 --- /dev/null +++ b/apps/web/utils/email/watch-manager.ts @@ -0,0 +1,203 @@ +import prisma from "@/utils/prisma"; +import { hasAiAccess } from "@/utils/premium"; +import { createScopedLogger } from "@/utils/logger"; +import { createEmailProvider } from "@/utils/email/provider"; +import { watchEmails } from "@/app/api/watch/controller"; + +const logger = createScopedLogger("email/watch-manager"); + +export type WatchEmailAccountResult = + | { + emailAccountId: string; + status: "success"; + expirationDate: Date; + } + | { + emailAccountId: string; + status: "error"; + message: string; + errorDetails?: string; + }; + +export async function ensureEmailAccountsWatched({ + userIds, +}: { + userIds: string[] | null; +}): Promise { + const emailAccounts = await getEmailAccountsToWatch(userIds); + return await watchEmailAccounts(emailAccounts); +} + +async function getEmailAccountsToWatch(userIds: string[] | null) { + return prisma.emailAccount.findMany({ + where: { + ...(userIds ? { userId: { in: userIds } } : {}), + user: { + premium: { + OR: [ + { lemonSqueezyRenewsAt: { gt: new Date() } }, + { stripeSubscriptionStatus: { in: ["active", "trialing"] } }, + ], + }, + }, + }, + select: { + id: true, + email: true, + watchEmailsExpirationDate: true, + watchEmailsSubscriptionId: true, + account: { + select: { + provider: true, + access_token: true, + refresh_token: true, + expires_at: true, + }, + }, + user: { + select: { + id: true, + aiApiKey: true, + premium: { + select: { + tier: true, + lemonSqueezyRenewsAt: true, + stripeSubscriptionStatus: true, + }, + }, + }, + }, + }, + orderBy: { + watchEmailsExpirationDate: { sort: "asc", nulls: "first" }, + }, + }); +} + +async function watchEmailAccounts( + emailAccounts: Awaited>, +): Promise { + if (!emailAccounts.length) return []; + + logger.info("Watching email accounts", { count: emailAccounts.length }); + + const results: WatchEmailAccountResult[] = []; + + for (const emailAccount of emailAccounts) { + try { + const result = await watchEmailAccount(emailAccount); + if (result) results.push(result); + } catch (error) { + if (error instanceof Error) { + const warn = [ + "invalid_grant", + "Mail service not enabled", + "Insufficient Permission", + ]; + + if (warn.some((w) => error.message.includes(w))) { + logger.warn("Not watching emails for user", { + email: emailAccount.email, + error, + }); + continue; + } + } + + logger.error("Error for user", { + email: emailAccount.email, + error, + }); + results.push({ + emailAccountId: emailAccount.id, + status: "error", + message: + "An unexpected error occurred while setting up watch for this account.", + errorDetails: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +async function watchEmailAccount( + emailAccount: Awaited>[number], +): Promise { + const { account, user, watchEmailsExpirationDate } = emailAccount; + + const userHasAiAccess = hasAiAccess( + user.premium?.tier || null, + user.aiApiKey, + ); + + if (!userHasAiAccess) { + logger.info("User does not have access to AI or cold email", { + email: emailAccount.email, + }); + + if ( + watchEmailsExpirationDate && + new Date(watchEmailsExpirationDate) < new Date() + ) { + await prisma.emailAccount.update({ + where: { id: emailAccount.id }, + data: { + watchEmailsExpirationDate: null, + watchEmailsSubscriptionId: null, + }, + }); + } + + return null; + } + + if (!account?.access_token || !account?.refresh_token) { + logger.info("User has no access token or refresh token", { + email: emailAccount.email, + }); + + return { + emailAccountId: emailAccount.id, + status: "error", + message: "Missing authentication tokens.", + }; + } + + logger.info("Watching emails for account", { + emailAccountId: emailAccount.id, + email: emailAccount.email, + provider: account.provider, + }); + + const provider = await createEmailProvider({ + emailAccountId: emailAccount.id, + provider: account.provider, + }); + + const result = await watchEmails({ + emailAccountId: emailAccount.id, + provider, + }); + + if (!result.success) { + logger.error("Failed to watch emails for account", { + emailAccountId: emailAccount.id, + email: emailAccount.email, + error: result.error, + }); + + return { + emailAccountId: emailAccount.id, + status: "error", + message: "Failed to set up watch for this account.", + errorDetails: result.error, + }; + } + + return { + emailAccountId: emailAccount.id, + status: "success", + expirationDate: result.expirationDate, + }; +} diff --git a/apps/web/utils/premium/server.ts b/apps/web/utils/premium/server.ts index 15a9953b8a..5622f74ef3 100644 --- a/apps/web/utils/premium/server.ts +++ b/apps/web/utils/premium/server.ts @@ -1,9 +1,11 @@ import sumBy from "lodash/sumBy"; +import { after } from "next/server"; import { updateSubscriptionItemQuantity } from "@/ee/billing/lemon/index"; import { updateStripeSubscriptionItemQuantity } from "@/ee/billing/stripe/index"; import prisma from "@/utils/prisma"; -import type { PremiumTier } from "@prisma/client"; +import type { PremiumTier, Prisma } from "@prisma/client"; import { createScopedLogger } from "@/utils/logger"; +import { ensureEmailAccountsWatched } from "@/utils/email/watch-manager"; import { hasTierAccess, isPremium } from "@/utils/premium"; import { SafeError } from "@/utils/error"; @@ -23,30 +25,44 @@ export async function upgradeToPremiumLemon(options: { lemonLicenseInstanceId?: string; emailAccountsAccess?: number; }) { - const { userId: _userId, ...data } = options; + const { userId, ...data } = options; const user = await prisma.user.findUnique({ - where: { id: options.userId }, + where: { id: userId }, select: { premiumId: true }, }); - if (!user) throw new Error(`User not found for id ${options.userId}`); + if (!user) { + logger.error("User not found", { userId }); + throw new Error("User not found"); + } - if (user.premiumId) { - return await prisma.premium.update({ - where: { id: user.premiumId }, - data, - select: { users: { select: { email: true } } }, + const premiumRecord = user.premiumId + ? await prisma.premium.update({ + where: { id: user.premiumId }, + data, + select: { users: { select: { id: true, email: true } } }, + }) + : await prisma.premium.create({ + data: { + users: { connect: { id: userId } }, + admins: { connect: { id: userId } }, + ...data, + }, + select: { users: { select: { id: true, email: true } } }, + }); + + after(() => { + const userIds = premiumRecord.users.map((premiumUser) => premiumUser.id); + ensureEmailAccountsWatched({ userIds }).catch((error) => { + logger.error("Failed to ensure email watches after premium upgrade", { + userIds, + error, + }); }); - } - return await prisma.premium.create({ - data: { - users: { connect: { id: options.userId } }, - admins: { connect: { id: options.userId } }, - ...data, - }, - select: { users: { select: { email: true } } }, }); + + return premiumRecord; } export async function extendPremiumLemon(options: { diff --git a/version.txt b/version.txt index ff12bbb1ec..7c0c5c4665 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.18.0 +v2.18.1