diff --git a/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx b/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx index bb0ce9acba..985542dadd 100644 --- a/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx +++ b/apps/web/app/(app)/automation/ProcessingPromptFileDialog.tsx @@ -52,7 +52,7 @@ export function ProcessingPromptFileDialog({ }, []); useEffect(() => { - if (currentStep >= STEPS) { + if (currentStep > 0) { setViewedProcessingPromptFileDialog(true); } }, [currentStep, setViewedProcessingPromptFileDialog]); diff --git a/apps/web/app/(app)/automation/consts.ts b/apps/web/app/(app)/automation/consts.ts new file mode 100644 index 0000000000..8edef01e7e --- /dev/null +++ b/apps/web/app/(app)/automation/consts.ts @@ -0,0 +1 @@ +export const ONBOARDING_COOKIE_NAME = "viewed_onboarding"; diff --git a/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx b/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx new file mode 100644 index 0000000000..81f79c6f5b --- /dev/null +++ b/apps/web/app/(app)/automation/onboarding/CategoriesSetup.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useCallback } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import type { ControllerRenderProps } from "react-hook-form"; +import { + Mail, + Newspaper, + Megaphone, + Calendar, + Receipt, + Bell, + Users, +} from "lucide-react"; +import { TypographyH3, TypographyP } from "@/components/Typography"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Form, FormControl, FormField, FormItem } from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { createRulesOnboardingAction } from "@/utils/actions/rule"; +import { isActionError } from "@/utils/error"; +import { toastError } from "@/components/Toast"; +import { + createRulesOnboardingBody, + type CreateRulesOnboardingBody, +} from "@/utils/actions/rule.validation"; + +const NEXT_URL = "/automation/onboarding/draft-replies"; + +export function CategoriesSetup() { + const router = useRouter(); + + const form = useForm({ + resolver: zodResolver(createRulesOnboardingBody), + defaultValues: { + toReply: "label", + newsletters: "label", + marketing: "label_archive", + calendar: "label", + receipts: "label", + notifications: "label", + coldEmails: "label_archive", + }, + }); + + const onSubmit = useCallback( + async (data: CreateRulesOnboardingBody) => { + // runs in background so we can move on to next step faster + createRulesOnboardingAction(data); + router.push(NEXT_URL); + }, + [router], + ); + + return ( +
+ + Set up your assistant + + + Choose how you want your emails organized. +
+ You can add custom categories and rules later. +
+ +
+ } + form={form} + /> + } + form={form} + /> + } + form={form} + /> + } + form={form} + /> + } + form={form} + /> + } + form={form} + /> + } + form={form} + /> +
+ +
+ + + +
+
+ + ); +} + +function CategoryCard({ + id, + label, + icon, + form, +}: { + id: keyof CreateRulesOnboardingBody; + label: string; + icon: React.ReactNode; + form: ReturnType>; +}) { + return ( + + + {icon} +
{label}
+
+ ; + }) => ( + + + + )} + /> +
+
+
+ ); +} diff --git a/apps/web/app/(app)/automation/onboarding/completed/page.tsx b/apps/web/app/(app)/automation/onboarding/completed/page.tsx new file mode 100644 index 0000000000..1378e6994f --- /dev/null +++ b/apps/web/app/(app)/automation/onboarding/completed/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import Link from "next/link"; +import { Check } from "lucide-react"; +import { TypographyH3, TypographyP } from "@/components/Typography"; +import { Card } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; + +export default function CompletedPage() { + return ( +
+ +
+
+ +
+ + You're all set! + +
+ + We've configured your inbox with smart defaults to help you stay + organized. Your emails will be automatically categorized, and + we'll draft replies that match your writing style. + + + + Want to customize further? You can create custom rules, and + fine-tune your preferences anytime. + +
+ +
+ + + {/* */} +
+
+
+
+ ); +} diff --git a/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx b/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx new file mode 100644 index 0000000000..738dbec7a2 --- /dev/null +++ b/apps/web/app/(app)/automation/onboarding/draft-replies/page.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Card } from "@/components/ui/card"; +import { TypographyH3, TypographyP } from "@/components/Typography"; +import { ButtonListSurvey } from "@/components/ButtonListSurvey"; +import { enableDraftRepliesAction } from "@/utils/actions/rule"; +import { isActionError } from "@/utils/error"; +import { toastError } from "@/components/Toast"; +import { ONBOARDING_COOKIE_NAME } from "@/app/(app)/automation/consts"; + +export default function DraftRepliesPage() { + const router = useRouter(); + + const onSetDraftReplies = useCallback( + async (value: string) => { + if (value === "yes") { + const result = await enableDraftRepliesAction({ enable: true }); + + if (isActionError(result)) { + toastError({ + description: "There was an error enabling draft replies", + }); + } + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${ONBOARDING_COOKIE_NAME}=true; path=/; max-age=${Number.MAX_SAFE_INTEGER}`; + + router.push("/automation/onboarding/completed"); + }, + [router], + ); + + return ( +
+ +
+ + Would you like AI to draft your email replies? + + + + AI will match your writing style and suggest drafts in Gmail. You + control when and what to send. + + + +
+
+
+ ); +} diff --git a/apps/web/app/(app)/automation/onboarding/page.tsx b/apps/web/app/(app)/automation/onboarding/page.tsx new file mode 100644 index 0000000000..bd457c92cc --- /dev/null +++ b/apps/web/app/(app)/automation/onboarding/page.tsx @@ -0,0 +1,10 @@ +import { Card } from "@/components/ui/card"; +import { CategoriesSetup } from "./CategoriesSetup"; + +export default function OnboardingPage() { + return ( + + + + ); +} diff --git a/apps/web/app/(app)/automation/page.tsx b/apps/web/app/(app)/automation/page.tsx index c5d6bc4a84..9573cb1762 100644 --- a/apps/web/app/(app)/automation/page.tsx +++ b/apps/web/app/(app)/automation/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from "react"; +import Link from "next/link"; +import { cookies } from "next/headers"; import { redirect } from "next/navigation"; +import prisma from "@/utils/prisma"; import { History } from "@/app/(app)/automation/History"; import { Pending } from "@/app/(app)/automation/Pending"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -12,6 +15,8 @@ import { OnboardingModal } from "@/components/OnboardingModal"; import { PermissionsCheck } from "@/app/(app)/PermissionsCheck"; import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; +import { ONBOARDING_COOKIE_NAME } from "@/app/(app)/automation/consts"; +import { Button } from "@/components/ui/button"; export const maxDuration = 300; // Applies to the actions @@ -19,6 +24,22 @@ export default async function AutomationPage() { const session = await auth(); if (!session?.user) redirect("/login"); + // onboarding redirect + const cookieStore = await cookies(); + const viewedOnboarding = + cookieStore.get(ONBOARDING_COOKIE_NAME)?.value === "true"; + + if (!viewedOnboarding) { + const hasRule = await prisma.rule.findFirst({ + where: { userId: session.user.id }, + select: { id: true }, + }); + + if (!hasRule) { + redirect("/automation/onboarding"); + } + } + return ( @@ -37,16 +58,22 @@ export default async function AutomationPage() { - - Learn how to use the AI Personal Assistant to automatically - label, archive, and more. - - } - videoId="SoeNDVr7ve4" - /> +
+ + + + Learn how to use the AI Personal Assistant to automatically + label, archive, and more. + + } + videoId="SoeNDVr7ve4" + /> +
diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx index f2e2c6504f..ada9e4bff0 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailPromptForm.tsx @@ -4,14 +4,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/Input"; import { - type UpdateColdEmailSettingsBody, - updateColdEmailSettingsBody, -} from "@/app/api/user/settings/cold-email/validation"; -import type { SaveEmailUpdateSettingsResponse } from "@/app/api/user/settings/email-updates/route"; -import { postRequest } from "@/utils/api"; + updateColdEmailPromptBody, + type UpdateColdEmailPromptBody, +} from "@/utils/actions/cold-email.validation"; import { DEFAULT_COLD_EMAIL_PROMPT } from "@/utils/cold-email/prompt"; import { toastError, toastSuccess } from "@/components/Toast"; -import { isErrorMessage } from "@/utils/error"; +import { isActionError } from "@/utils/error"; +import { updateColdEmailPromptAction } from "@/utils/actions/cold-email"; export function ColdEmailPromptForm(props: { coldEmailPrompt?: string | null; @@ -21,8 +20,8 @@ export function ColdEmailPromptForm(props: { register, handleSubmit, formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(updateColdEmailSettingsBody), + } = useForm({ + resolver: zodResolver(updateColdEmailPromptBody), defaultValues: { coldEmailPrompt: props.coldEmailPrompt || DEFAULT_COLD_EMAIL_PROMPT, }, @@ -30,12 +29,9 @@ export function ColdEmailPromptForm(props: { const { onSuccess } = props; - const onSubmit: SubmitHandler = useCallback( + const onSubmit: SubmitHandler = useCallback( async (data) => { - const res = await postRequest< - SaveEmailUpdateSettingsResponse, - UpdateColdEmailSettingsBody - >("/api/user/settings/cold-email", { + const result = await updateColdEmailPromptAction({ // if user hasn't changed the prompt, unset their custom prompt coldEmailPrompt: !data.coldEmailPrompt || @@ -44,7 +40,7 @@ export function ColdEmailPromptForm(props: { : data.coldEmailPrompt, }); - if (isErrorMessage(res)) { + if (isActionError(result)) { toastError({ description: "Error updating cold email prompt." }); } else { toastSuccess({ description: "Prompt updated!" }); diff --git a/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx b/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx index 7620c47a30..c9cb97ea96 100644 --- a/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx +++ b/apps/web/app/(app)/cold-email-blocker/ColdEmailSettings.tsx @@ -2,18 +2,17 @@ import { useCallback, useMemo } from "react"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; -import type { SaveEmailUpdateSettingsResponse } from "@/app/api/user/settings/email-updates/route"; import { LoadingContent } from "@/components/LoadingContent"; import { toastError, toastSuccess } from "@/components/Toast"; -import { postRequest } from "@/utils/api"; import { zodResolver } from "@hookform/resolvers/zod"; import { ColdEmailSetting } from "@prisma/client"; -import { isError } from "@/utils/error"; +import { isActionError } from "@/utils/error"; import { Button } from "@/components/ui/button"; import { type UpdateColdEmailSettingsBody, updateColdEmailSettingsBody, -} from "@/app/api/user/settings/cold-email/validation"; +} from "@/utils/actions/cold-email.validation"; +import { updateColdEmailSettingsAction } from "@/utils/actions/cold-email"; import { ColdEmailPromptForm } from "@/app/(app)/cold-email-blocker/ColdEmailPromptForm"; import { RadioGroup } from "@/components/RadioGroup"; import { useUser } from "@/hooks/useUser"; @@ -58,14 +57,9 @@ export function ColdEmailForm({ const onSubmit: SubmitHandler = useCallback( async (data) => { - const res = await postRequest< - SaveEmailUpdateSettingsResponse, - UpdateColdEmailSettingsBody - >("/api/user/settings/cold-email", { - coldEmailBlocker: data.coldEmailBlocker, - }); + const result = await updateColdEmailSettingsAction(data); - if (isError(res)) { + if (isActionError(result)) { toastError({ description: "There was an error updating the settings.", }); diff --git a/apps/web/app/(app)/setup/page.tsx b/apps/web/app/(app)/setup/page.tsx index 01e1030ffa..af30c1d0ce 100644 --- a/apps/web/app/(app)/setup/page.tsx +++ b/apps/web/app/(app)/setup/page.tsx @@ -33,7 +33,6 @@ export default async function SetupPage() { if (!user) throw new Error("User not found"); const isReplyTrackerConfigured = user.rules.some((rule) => rule.trackReplies); - const isColdEmailBlockerConfigured = !!user.coldEmailBlocker; const isAiAssistantConfigured = user.rules.some((rule) => !rule.trackReplies); const isBulkUnsubscribeConfigured = user.newsletters.length > 0; @@ -41,7 +40,6 @@ export default async function SetupPage() { <> @@ -129,18 +127,15 @@ function FeatureGrid() { function SetupContent({ isReplyTrackerConfigured, - isColdEmailBlockerConfigured, isBulkUnsubscribeConfigured, isAiAssistantConfigured, }: { isReplyTrackerConfigured: boolean; - isColdEmailBlockerConfigured: boolean; isBulkUnsubscribeConfigured: boolean; isAiAssistantConfigured: boolean; }) { const steps = [ isReplyTrackerConfigured, - isColdEmailBlockerConfigured, isBulkUnsubscribeConfigured, isAiAssistantConfigured, ]; @@ -169,7 +164,6 @@ function SetupContent({ ) : ( { return ( ) : (
- {actionButton || "Enable"} + View
)} @@ -248,7 +240,6 @@ function Checklist({ totalSteps, progressPercentage, isReplyTrackerConfigured, - isColdEmailBlockerConfigured, isBulkUnsubscribeConfigured, isAiAssistantConfigured, }: { @@ -256,7 +247,6 @@ function Checklist({ totalSteps: number; progressPercentage: number; isReplyTrackerConfigured: boolean; - isColdEmailBlockerConfigured: boolean; isBulkUnsubscribeConfigured: boolean; isAiAssistantConfigured: boolean; }) { @@ -280,25 +270,14 @@ function Checklist({ } - iconBg="bg-blue-100 dark:bg-blue-900/50" - iconColor="text-blue-500 dark:text-blue-400" - title="Enable Reply Zero" - description="Track emails needing replies & follow-ups. Get AI-drafted responses" - timeEstimate="30 seconds" - completed={isReplyTrackerConfigured} - /> - - } - iconBg="bg-orange-100 dark:bg-orange-900/50" - iconColor="text-orange-500 dark:text-orange-400" - title="Enable Cold Email Blocker" - description="Filter out unsolicited messages" - timeEstimate="30 seconds" - completed={isColdEmailBlockerConfigured} + href="/automation/onboarding" + icon={} + iconBg="bg-green-100 dark:bg-green-900/50" + iconColor="text-green-500 dark:text-green-400" + title="Set up AI Assistant" + description="Your personal email assistant that organizes, archives, and drafts replies based on your rules" + timeEstimate="5 minutes" + completed={isAiAssistantConfigured} /> } - iconBg="bg-green-100 dark:bg-green-900/50" - iconColor="text-green-500 dark:text-green-400" - title="Set up AI Assistant" - description="Your personal email assistant that organizes, archives, and drafts replies based on your rules" - timeEstimate="10 minutes" - completed={isAiAssistantConfigured} + href="/reply-zero" + icon={} + iconBg="bg-blue-100 dark:bg-blue-900/50" + iconColor="text-blue-500 dark:text-blue-400" + title="View emails needing replies" + description="Track emails needing replies & follow-ups. Get AI-drafted responses" + timeEstimate="30 seconds" + completed={isReplyTrackerConfigured} /> ); diff --git a/apps/web/app/api/google/watch/controller.ts b/apps/web/app/api/google/watch/controller.ts index 40fa153eba..061bb3a5a2 100644 --- a/apps/web/app/api/google/watch/controller.ts +++ b/apps/web/app/api/google/watch/controller.ts @@ -43,7 +43,7 @@ export async function unwatchEmails({ await unwatch(gmail); } catch (error) { if (error instanceof Error && error.message.includes("invalid_grant")) { - logger.error("Error unwatching emails, invalid grant", { userId }); + logger.warn("Error unwatching emails, invalid grant", { userId }); return; } diff --git a/apps/web/app/api/reply-tracker/process-previous/route.ts b/apps/web/app/api/reply-tracker/process-previous/route.ts new file mode 100644 index 0000000000..9ba0e270b0 --- /dev/null +++ b/apps/web/app/api/reply-tracker/process-previous/route.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; +import { NextResponse } from "next/server"; +import { withError } from "@/utils/middleware"; +import { processPreviousSentEmails } from "@/utils/reply-tracker/check-previous-emails"; +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() }); +export type ProcessPreviousBody = z.infer; + +export const POST = withError(async (request: Request) => { + if (!isValidInternalApiKey(await headers())) { + logger.error("Invalid API key"); + return NextResponse.json({ error: "Invalid API key" }); + } + + const json = await request.json(); + const body = processPreviousSchema.parse(json); + + const user = await prisma.user.findUnique({ + where: { id: body.userId }, + include: { + accounts: { + where: { provider: "google" }, + select: { access_token: true, refresh_token: true }, + }, + }, + }); + if (!user) return NextResponse.json({ error: "User not found" }); + + logger.info("Processing previous emails for user", { userId: user.id }); + + const account = user.accounts[0]; + if (!account) return NextResponse.json({ error: "No Google account found" }); + if (!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, + }); + + await processPreviousSentEmails(gmail, user); + + return NextResponse.json({ success: true }); +}); diff --git a/apps/web/app/api/user/settings/cold-email/route.ts b/apps/web/app/api/user/settings/cold-email/route.ts deleted file mode 100644 index fe50027aa1..0000000000 --- a/apps/web/app/api/user/settings/cold-email/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import prisma from "@/utils/prisma"; -import { withError } from "@/utils/middleware"; -import { - type UpdateColdEmailSettingsBody, - updateColdEmailSettingsBody, -} from "@/app/api/user/settings/cold-email/validation"; -import { SafeError } from "@/utils/error"; - -export type UpdateColdEmailSettingsResponse = Awaited< - ReturnType ->; - -async function updateColdEmailSettings(options: UpdateColdEmailSettingsBody) { - const session = await auth(); - if (!session?.user.email) throw new SafeError("Not logged in"); - - return await prisma.user.update({ - where: { email: session.user.email }, - data: { - coldEmailBlocker: options.coldEmailBlocker, - coldEmailPrompt: options.coldEmailPrompt, - }, - }); -} - -export const POST = withError(async (request: Request) => { - const session = await auth(); - if (!session?.user.email) - return NextResponse.json({ error: "Not authenticated" }); - - const json = await request.json(); - const body = updateColdEmailSettingsBody.parse(json); - - const result = await updateColdEmailSettings(body); - - return NextResponse.json(result); -}); diff --git a/apps/web/app/api/user/settings/cold-email/validation.ts b/apps/web/app/api/user/settings/cold-email/validation.ts deleted file mode 100644 index 27c4832532..0000000000 --- a/apps/web/app/api/user/settings/cold-email/validation.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { z } from "zod"; -import { ColdEmailSetting } from "@prisma/client"; - -export const updateColdEmailSettingsBody = z.object({ - coldEmailBlocker: z - .enum([ - ColdEmailSetting.DISABLED, - ColdEmailSetting.LIST, - ColdEmailSetting.LABEL, - ColdEmailSetting.ARCHIVE_AND_LABEL, - ColdEmailSetting.ARCHIVE_AND_READ_AND_LABEL, - ]) - .nullish(), - coldEmailPrompt: z.string().nullish(), -}); -export type UpdateColdEmailSettingsBody = z.infer< - typeof updateColdEmailSettingsBody ->; diff --git a/apps/web/components/SideNav.tsx b/apps/web/components/SideNav.tsx index 339bc28ad1..cc0ad1b361 100644 --- a/apps/web/components/SideNav.tsx +++ b/apps/web/components/SideNav.tsx @@ -61,7 +61,7 @@ type NavItem = { const navigationItems: NavItem[] = [ { - name: "AI Personal Assistant", + name: "Personal Assistant", href: "/automation", icon: SparklesIcon, }, diff --git a/apps/web/components/ui/form.tsx b/apps/web/components/ui/form.tsx new file mode 100644 index 0000000000..c7752d6c69 --- /dev/null +++ b/apps/web/components/ui/form.tsx @@ -0,0 +1,179 @@ +"use client"; + +import * as React from "react"; +import type * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + FormProvider, + useFormContext, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form"; + +import { cn } from "@/utils"; +import { Label } from "@/components/ui//label"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +); + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId(); + + return ( + +
+ + ); +}); +FormItem.displayName = "FormItem"; + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +