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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ 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;
}

export function OnboardingContent({ step }: OnboardingContentProps) {
const { emailAccountId, provider, isLoading } = useAccount();
const { isPremium } = usePremium();

useSignUpEvent();

Expand Down Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 (
Expand Down Expand Up @@ -61,6 +66,7 @@ const AboutSectionForm = ({
handleSubmit,
} = useForm<SaveAboutBody>({
defaultValues: { about: about ?? "" },
resolver: zodResolver(saveAboutBody),
});

const { emailAccountId } = useAccount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -29,6 +29,7 @@ export const SignatureSectionForm = ({

const { handleSubmit, setValue } = useForm<SaveSignatureBody>({
defaultValues: { signature: defaultSignature },
resolver: zodResolver(saveSignatureBody),
});

const editorRef = useRef<TiptapHandle>(null);
Expand Down
10 changes: 4 additions & 6 deletions apps/web/utils/actions/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof saveAboutBody>;
import {
saveAboutBody,
saveSignatureBody,
} from "@/utils/actions/user.validation";

export const saveAboutAction = actionClient
.metadata({ name: "saveAbout" })
Expand All @@ -23,9 +24,6 @@ export const saveAboutAction = actionClient
});
});

const saveSignatureBody = z.object({ signature: z.string().max(2000) });
export type SaveSignatureBody = z.infer<typeof saveSignatureBody>;

export const saveSignatureAction = actionClient
.metadata({ name: "saveSignature" })
.schema(saveSignatureBody)
Expand Down
9 changes: 9 additions & 0 deletions apps/web/utils/actions/user.validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { z } from "zod";

export const saveAboutBody = z.object({ about: z.string().max(2000) });
export type SaveAboutBody = z.infer<typeof saveAboutBody>;

export const saveSignatureBody = z.object({
signature: z.string().max(10_000),
});
export type SaveSignatureBody = z.infer<typeof saveSignatureBody>;
4 changes: 2 additions & 2 deletions apps/web/utils/gmail/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ export async function withGmailRetry<T>(
/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,
});
Expand Down
3 changes: 2 additions & 1 deletion apps/web/utils/mcp/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions apps/web/utils/outlook/subscription-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
137 changes: 92 additions & 45 deletions apps/web/utils/outlook/subscription-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<Date | null> {
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: {
Expand All @@ -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<Date | null> {
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();
}
Loading
Loading