diff --git a/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingContent.tsx b/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingContent.tsx index e864cfb5c..d76071e53 100644 --- a/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingContent.tsx +++ b/apps/web/app/(app)/[emailAccountId]/onboarding/OnboardingContent.tsx @@ -21,6 +21,7 @@ import { useAccount } from "@/providers/EmailAccountProvider"; import { useSignUpEvent } from "@/hooks/useSignupEvent"; import { isDefined } from "@/utils/types"; import { StepCompanySize } from "@/app/(app)/[emailAccountId]/onboarding/StepCompanySize"; +import { usePremium } from "@/components/PremiumAlert"; interface OnboardingContentProps { step: number; @@ -28,6 +29,7 @@ interface OnboardingContentProps { export function OnboardingContent({ step }: OnboardingContentProps) { const { emailAccountId, provider, isLoading } = useAccount(); + const { isPremium } = usePremium(); useSignUpEvent(); @@ -80,9 +82,13 @@ export function OnboardingContent({ step }: OnboardingContentProps) { analytics.onComplete(); markOnboardingAsCompleted(ASSISTANT_ONBOARDING_COOKIE); await completedOnboardingAction(); - router.push("/welcome-upgrade"); + if (isPremium) { + router.push(prefixPath(emailAccountId, "/setup")); + } else { + router.push("/welcome-upgrade"); + } } - }, [router, emailAccountId, analytics, clampedStep, steps.length]); + }, [router, emailAccountId, analytics, clampedStep, steps.length, isPremium]); // Trigger persona analysis on mount (first step only) useEffect(() => { diff --git a/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx index 2d89b2bd9..49390eac9 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/AboutSectionForm.tsx @@ -4,7 +4,7 @@ import { useForm } from "react-hook-form"; import { useAction } from "next-safe-action/hooks"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; -import { saveAboutAction, type SaveAboutBody } from "@/utils/actions/user"; +import { saveAboutAction } from "@/utils/actions/user"; import { FormSection, FormSectionLeft, @@ -15,6 +15,11 @@ import { toastError, toastSuccess } from "@/components/Toast"; import { useEmailAccountFull } from "@/hooks/useEmailAccountFull"; import { Skeleton } from "@/components/ui/skeleton"; import { LoadingContent } from "@/components/LoadingContent"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + type SaveAboutBody, + saveAboutBody, +} from "@/utils/actions/user.validation"; export function AboutSectionFull() { return ( @@ -61,6 +66,7 @@ const AboutSectionForm = ({ handleSubmit, } = useForm({ defaultValues: { about: about ?? "" }, + resolver: zodResolver(saveAboutBody), }); const { emailAccountId } = useAccount(); diff --git a/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx index 07a50e94b..16547604a 100644 --- a/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx +++ b/apps/web/app/(app)/[emailAccountId]/settings/SignatureSectionForm.tsx @@ -4,10 +4,8 @@ import { useCallback, useRef } from "react"; import { useForm } from "react-hook-form"; import { useAction } from "next-safe-action/hooks"; import { Button } from "@/components/Button"; -import { - saveSignatureAction, - type SaveSignatureBody, -} from "@/utils/actions/user"; +import { saveSignatureAction } from "@/utils/actions/user"; +import type { SaveSignatureBody } from "@/utils/actions/user.validation"; import { fetchSignaturesFromProviderAction } from "@/utils/actions/email-account"; import { FormSection, @@ -19,6 +17,8 @@ import { Tiptap, type TiptapHandle } from "@/components/editor/Tiptap"; import { toastError, toastInfo, toastSuccess } from "@/components/Toast"; import { ClientOnly } from "@/components/ClientOnly"; import { useAccount } from "@/providers/EmailAccountProvider"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { saveSignatureBody } from "@/utils/actions/user.validation"; export const SignatureSectionForm = ({ signature, @@ -29,6 +29,7 @@ export const SignatureSectionForm = ({ const { handleSubmit, setValue } = useForm({ defaultValues: { signature: defaultSignature }, + resolver: zodResolver(saveSignatureBody), }); const editorRef = useRef(null); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index ad5cc6755..47b8607b9 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -9,9 +9,10 @@ import { SafeError } from "@/utils/error"; import { updateAccountSeats } from "@/utils/premium/server"; import { betterAuthConfig } from "@/utils/auth"; import { headers } from "next/headers"; - -const saveAboutBody = z.object({ about: z.string().max(2000) }); -export type SaveAboutBody = z.infer; +import { + saveAboutBody, + saveSignatureBody, +} from "@/utils/actions/user.validation"; export const saveAboutAction = actionClient .metadata({ name: "saveAbout" }) @@ -23,9 +24,6 @@ export const saveAboutAction = actionClient }); }); -const saveSignatureBody = z.object({ signature: z.string().max(2000) }); -export type SaveSignatureBody = z.infer; - export const saveSignatureAction = actionClient .metadata({ name: "saveSignature" }) .schema(saveSignatureBody) diff --git a/apps/web/utils/actions/user.validation.ts b/apps/web/utils/actions/user.validation.ts new file mode 100644 index 000000000..87258a0fa --- /dev/null +++ b/apps/web/utils/actions/user.validation.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +export const saveAboutBody = z.object({ about: z.string().max(2000) }); +export type SaveAboutBody = z.infer; + +export const saveSignatureBody = z.object({ + signature: z.string().max(10_000), +}); +export type SaveSignatureBody = z.infer; diff --git a/apps/web/utils/gmail/retry.ts b/apps/web/utils/gmail/retry.ts index f11f6cf83..0c48394d2 100644 --- a/apps/web/utils/gmail/retry.ts +++ b/apps/web/utils/gmail/retry.ts @@ -38,8 +38,8 @@ export async function withGmailRetry( /quota exceeded/i.test(errorMessage); if (!isRateLimitError) { - logger.error("Non-rate limit error encountered, not retrying", { - errorMessage, + logger.warn("Non-rate limit error encountered, not retrying", { + error, status, reason, }); diff --git a/apps/web/utils/mcp/integrations.ts b/apps/web/utils/mcp/integrations.ts index ab1c089e2..bcefc36a1 100644 --- a/apps/web/utils/mcp/integrations.ts +++ b/apps/web/utils/mcp/integrations.ts @@ -115,7 +115,8 @@ export const MCP_INTEGRATIONS: Record< "timeline", ], oauthConfig: { - authorization_endpoint: "https://mcp.hubspot.com/oauth/authorize/user", + // authorization_endpoint: "https://mcp.hubspot.com/oauth/authorize/user", + authorization_endpoint: "https://app.hubspot.com/oauth/authorize", token_endpoint: "https://mcp.hubspot.com/oauth/v1/token", }, comingSoon: true, diff --git a/apps/web/utils/outlook/subscription-manager.test.ts b/apps/web/utils/outlook/subscription-manager.test.ts index 4dee37ef1..e2a394d19 100644 --- a/apps/web/utils/outlook/subscription-manager.test.ts +++ b/apps/web/utils/outlook/subscription-manager.test.ts @@ -55,7 +55,9 @@ describe("OutlookSubscriptionManager", () => { existingSubscriptionId, ); expect(mockProvider.watchEmails).toHaveBeenCalled(); - expect(result).toEqual(newSubscription); + expect(result).toEqual( + expect.objectContaining({ ...newSubscription, changed: true }), + ); }); it("should create subscription even if no existing subscription exists", async () => { @@ -76,7 +78,9 @@ describe("OutlookSubscriptionManager", () => { // Assert expect(mockProvider.unwatchEmails).not.toHaveBeenCalled(); expect(mockProvider.watchEmails).toHaveBeenCalled(); - expect(result).toEqual(newSubscription); + expect(result).toEqual( + expect.objectContaining({ ...newSubscription, changed: true }), + ); }); it("should continue creating subscription even if canceling old one fails", async () => { @@ -102,7 +106,9 @@ describe("OutlookSubscriptionManager", () => { // Assert expect(mockProvider.unwatchEmails).toHaveBeenCalled(); expect(mockProvider.watchEmails).toHaveBeenCalled(); - expect(result).toEqual(newSubscription); + expect(result).toEqual( + expect.objectContaining({ ...newSubscription, changed: true }), + ); }); it("should return null if creating new subscription fails", async () => { diff --git a/apps/web/utils/outlook/subscription-manager.ts b/apps/web/utils/outlook/subscription-manager.ts index 1d8574cee..7ac91e49c 100644 --- a/apps/web/utils/outlook/subscription-manager.ts +++ b/apps/web/utils/outlook/subscription-manager.ts @@ -3,8 +3,7 @@ import { createScopedLogger } from "@/utils/logger"; import { captureException } from "@/utils/error"; import type { EmailProvider } from "@/utils/email/types"; import { createEmailProvider } from "@/utils/email/provider"; - -const logger = createScopedLogger("outlook/subscription-manager"); +import type { Logger } from "@/utils/logger"; /** * Manages Outlook subscriptions, ensuring only one active subscription per email account @@ -13,87 +12,144 @@ const logger = createScopedLogger("outlook/subscription-manager"); export class OutlookSubscriptionManager { private readonly client: EmailProvider; private readonly emailAccountId: string; + private readonly logger: Logger; constructor(client: EmailProvider, emailAccountId: string) { this.client = client; this.emailAccountId = emailAccountId; + this.logger = createScopedLogger("outlook/subscription-manager").with({ + emailAccountId, + }); } - async createSubscription() { + async createSubscription(): Promise<{ + expirationDate: Date; + subscriptionId?: string; + changed: boolean; + } | null> { try { - logger.info("Creating new subscription", { - emailAccountId: this.emailAccountId, - }); + // Check if we already have a valid subscription and reuse it when possible + const existing = await this.getExistingSubscription(); + + if (existing?.subscriptionId && existing.expirationDate) { + const now = new Date(); + const renewalThresholdMs = 24 * 60 * 60 * 1000; // 24 hours + const timeUntilExpiry = + new Date(existing.expirationDate).getTime() - now.getTime(); + + if (timeUntilExpiry > renewalThresholdMs) { + this.logger.info("Existing subscription is valid; reuse", { + subscriptionId: existing.subscriptionId, + expirationDate: existing.expirationDate, + }); + return { + expirationDate: new Date(existing.expirationDate), + subscriptionId: existing.subscriptionId, + changed: false, + }; + } + this.logger.info("Existing subscription near expiry; renewing", { + subscriptionId: existing.subscriptionId, + expirationDate: existing.expirationDate, + }); + } else { + this.logger.info("No existing subscription found; creating new"); + } + + // If we got here, the subscription is missing or expiring soon. Cancel and create a new one. await this.cancelExistingSubscription(); - // Create new subscription const subscription = await this.client.watchEmails(); - logger.info("Successfully created new subscription", { - emailAccountId: this.emailAccountId, + this.logger.info("Successfully created new subscription", { subscriptionId: subscription?.subscriptionId, }); - return subscription; + return subscription + ? { + expirationDate: subscription.expirationDate, + subscriptionId: subscription.subscriptionId, + changed: true, + } + : null; } catch (error) { - logger.error("Failed to create subscription", { - emailAccountId: this.emailAccountId, - error, - }); + this.logger.error("Failed to create subscription", { error }); captureException(error); return null; } } + /** + * Ensures there is a valid subscription and persists it only when changed. + * Returns the active subscription expiration date or null on failure. + */ + async ensureSubscription(): Promise { + const result = await this.createSubscription(); + if (!result?.subscriptionId) return null; + + if (result.changed) { + await this.updateSubscriptionInDatabase({ + expirationDate: result.expirationDate, + subscriptionId: result.subscriptionId, + }); + } + + return result.expirationDate; + } + private async cancelExistingSubscription() { try { - const existingSubscriptionId = await this.getExistingSubscriptionId(); + const existing = await this.getExistingSubscription(); + const existingSubscriptionId = existing?.subscriptionId || null; if (existingSubscriptionId) { - logger.info("Canceling existing subscription", { - emailAccountId: this.emailAccountId, + this.logger.info("Canceling existing subscription", { existingSubscriptionId, }); try { await this.client.unwatchEmails(existingSubscriptionId); - logger.info("Successfully canceled existing subscription", { - emailAccountId: this.emailAccountId, + this.logger.info("Successfully canceled existing subscription", { existingSubscriptionId, }); } catch (error) { // Log but don't fail - the subscription might already be expired/invalid - logger.warn( + this.logger.warn( "Failed to cancel existing subscription (may already be expired)", { - emailAccountId: this.emailAccountId, existingSubscriptionId, error: error instanceof Error ? error.message : String(error), }, ); } } else { - logger.info("No existing subscription found", { - emailAccountId: this.emailAccountId, - }); + this.logger.info("No existing subscription found"); } } catch (error) { - logger.error("Error checking for existing subscription", { - emailAccountId: this.emailAccountId, - error, - }); + this.logger.error("Error checking for existing subscription", { error }); // Don't throw - we still want to try creating a new subscription } } - private async getExistingSubscriptionId() { + private async getExistingSubscription() { const emailAccount = await prisma.emailAccount.findUnique({ where: { id: this.emailAccountId }, - select: { watchEmailsSubscriptionId: true }, + select: { + watchEmailsSubscriptionId: true, + watchEmailsExpirationDate: true, + }, }); - return emailAccount?.watchEmailsSubscriptionId || null; + if (!emailAccount) return null; + + return { + subscriptionId: emailAccount.watchEmailsSubscriptionId || null, + expirationDate: emailAccount.watchEmailsExpirationDate || null, + } as { + subscriptionId: string | null; + expirationDate: Date | null; + }; } async updateSubscriptionInDatabase(subscription: { @@ -114,30 +170,21 @@ export class OutlookSubscriptionManager { }, }); - logger.info("Updated subscription in database", { - emailAccountId: this.emailAccountId, + this.logger.info("Updated subscription in database", { subscriptionId: subscription.subscriptionId, expirationDate, }); } } -export async function createManagedOutlookSubscription(emailAccountId: string) { +export async function createManagedOutlookSubscription( + emailAccountId: string, +): Promise { const provider = await createEmailProvider({ emailAccountId, provider: "microsoft", }); const manager = new OutlookSubscriptionManager(provider, emailAccountId); - const subscription = await manager.createSubscription(); - if (!subscription?.subscriptionId) { - return null; - } - - await manager.updateSubscriptionInDatabase({ - expirationDate: subscription.expirationDate, - subscriptionId: subscription.subscriptionId, - }); - - return subscription.expirationDate; + return await manager.ensureSubscription(); } diff --git a/version.txt b/version.txt index bb97e3eeb..e9c0ff5d7 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.16.7 +v2.16.9