diff --git a/.cursorrules b/.cursorrules index d90b323136..824d9c46f0 100644 --- a/.cursorrules +++ b/.cursorrules @@ -112,19 +112,23 @@ You are an expert in TypeScript, Node.js, Next.js App Router, React, Prisma, Pos - This is the format of a server action. Example: ```typescript - export async function processHistoryAction( - unsafeData: ProcessHistoryOptions - ): Promise { - const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; + export const deactivateApiKeyAction = withActionInstrumentation( + "deactivateApiKey", + async (unsafeData: DeactivateApiKeyBody) => { + const session = await auth(); + const userId = session?.user.id; + if (!userId) return { error: "Not logged in" }; - const data = processHistorySchema.safeParse(unsafeData); - if (!data.success) return { error: "Invalid data" }; + const { data, success, error } = + deactivateApiKeyBody.safeParse(unsafeData); + if (!success) return { error: error.message }; - // perform action - await processHistory(); + await prisma.apiKey.update({ + where: { id: data.id, userId }, + data: { isActive: false }, + }); - revalidatePath("/history"); - } + revalidatePath("/settings"); + } + ); ``` diff --git a/apps/web/app/(app)/ErrorMessages.tsx b/apps/web/app/(app)/ErrorMessages.tsx index eaa6af6c9b..971652c25f 100644 --- a/apps/web/app/(app)/ErrorMessages.tsx +++ b/apps/web/app/(app)/ErrorMessages.tsx @@ -30,7 +30,7 @@ export async function ErrorMessages() { className="mt-2" > diff --git a/apps/web/app/(app)/admin/AdminUserControls.tsx b/apps/web/app/(app)/admin/AdminUserControls.tsx index 9357945497..537b512cf4 100644 --- a/apps/web/app/(app)/admin/AdminUserControls.tsx +++ b/apps/web/app/(app)/admin/AdminUserControls.tsx @@ -41,7 +41,9 @@ export const AdminUserControls = () => { onClick={async () => { setIsProcessing(true); const email = getValues("email"); - const result = await adminProcessHistoryAction(email); + const result = await adminProcessHistoryAction({ + emailAddress: email, + }); handleActionResult(result, `Processed history for ${email}`); setIsProcessing(false); }} diff --git a/apps/web/app/(app)/automation/RulesPrompt.tsx b/apps/web/app/(app)/automation/RulesPrompt.tsx index 5467efcd64..082f1c038d 100644 --- a/apps/web/app/(app)/automation/RulesPrompt.tsx +++ b/apps/web/app/(app)/automation/RulesPrompt.tsx @@ -3,7 +3,6 @@ import { useCallback, useState } from "react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import Link from "next/link"; import { useForm } from "react-hook-form"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -30,6 +29,7 @@ import { SectionHeader } from "@/components/Typography"; import type { RulesPromptResponse } from "@/app/api/user/rules/prompt/route"; import { LoadingContent } from "@/components/LoadingContent"; import { Tooltip } from "@/components/Tooltip"; +import { handleActionCall } from "@/utils/server-action"; const examplePrompts = [ 'Label newsletters as "Newsletter" and archive them', @@ -96,24 +96,21 @@ function RulesPromptForm({ setIsSubmitting(true); const saveRulesPromise = async (data: SaveRulesPromptBody) => { - try { - const result = await saveRulesPromptAction(data); - console.log("saveRulesPromptAction completed", result); - setIsSubmitting(false); - if (isActionError(result)) { - throw new Error(result.error); - } - - router.push("/automation?tab=rules"); - mutate(); - setIsSubmitting(false); + setIsSubmitting(true); + const result = await handleActionCall("saveRulesPromptAction", () => + saveRulesPromptAction(data), + ); - return result; - } catch (error) { + if (isActionError(result)) { setIsSubmitting(false); - captureException(error); - throw error; + throw new Error(result.error); } + + router.push("/automation?tab=rules"); + mutate(); + setIsSubmitting(false); + + return result; }; toast.promise(() => saveRulesPromise(data), { @@ -121,11 +118,15 @@ function RulesPromptForm({ success: (result) => { const { createdRules, editedRules, removedRules } = result || {}; - return `Rules saved successfully! ${[ - createdRules ? `${createdRules} rules created. ` : "", - editedRules ? `${editedRules} rules edited. ` : "", - removedRules ? `${removedRules} rules removed. ` : "", - ].join("")}`; + const message = [ + createdRules ? `${createdRules} rules created.` : "", + editedRules ? `${editedRules} rules edited.` : "", + removedRules ? `${removedRules} rules removed.` : "", + ] + .filter(Boolean) + .join(" "); + + return `Rules saved successfully! ${message}`; }, error: (err) => { return `Error saving rules: ${err.message}`; @@ -198,27 +199,26 @@ Feel free to add as many as you want: if (isSubmitting || isGenerating) return; toast.promise( async () => { - try { - setIsGenerating(true); - const result = await generateRulesPromptAction(); - setIsGenerating(false); - if (isActionError(result)) - throw new Error(result.error); - if (!result) - throw new Error("Unable to generate prompt"); - - const currentPrompt = getValues("rulesPrompt"); - const updatedPrompt = currentPrompt - ? `${currentPrompt}\n\n${result.rulesPrompt}` - : result.rulesPrompt; - setValue("rulesPrompt", updatedPrompt.trim()); + setIsGenerating(true); + const result = await handleActionCall( + "generateRulesPromptAction", + generateRulesPromptAction, + ); - return result; - } catch (error) { + if (isActionError(result)) { setIsGenerating(false); - captureException(error); - throw error; + throw new Error(result.error); } + + const currentPrompt = getValues("rulesPrompt"); + const updatedPrompt = currentPrompt + ? `${currentPrompt}\n\n${result.rulesPrompt}` + : result.rulesPrompt; + setValue("rulesPrompt", updatedPrompt.trim()); + + setIsGenerating(false); + + return result; }, { loading: "Generating prompt...", diff --git a/apps/web/app/(landing)/components/page.tsx b/apps/web/app/(landing)/components/page.tsx index d119d4e3f5..b59c10392f 100644 --- a/apps/web/app/(landing)/components/page.tsx +++ b/apps/web/app/(landing)/components/page.tsx @@ -143,9 +143,10 @@ export default function Components() { - - - +
+ + +
); diff --git a/apps/web/components/email-list/EmailList.tsx b/apps/web/components/email-list/EmailList.tsx index 80a0965615..3a470ab64f 100644 --- a/apps/web/components/email-list/EmailList.tsx +++ b/apps/web/components/email-list/EmailList.tsx @@ -230,6 +230,8 @@ export function EmailList(props: { if (isActionError(result)) { setIsCategorizing((s) => ({ ...s, [thread.id]: false })); throw new Error(`There was an error categorizing the email.`); + } else if (!result) { + throw new Error("The request did not complete"); } else { // setCategory(res); refetch(); diff --git a/apps/web/utils/actions/admin.ts b/apps/web/utils/actions/admin.ts index 43dd1ae150..690c722140 100644 --- a/apps/web/utils/actions/admin.ts +++ b/apps/web/utils/actions/admin.ts @@ -3,27 +3,34 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; import { processHistoryForUser } from "@/app/api/google/webhook/process-history"; import { isAdmin } from "@/utils/admin"; -import type { ServerActionResponse } from "@/utils/error"; +import { withActionInstrumentation } from "@/utils/actions/middleware"; -export async function adminProcessHistoryAction( - emailAddress: string, - historyId?: number, - startHistoryId?: number, -): Promise { - const session = await auth(); - const userId = session?.user.id; - if (!userId) return { error: "Not logged in" }; - if (!isAdmin(session.user.email)) return { error: "Not admin" }; +export const adminProcessHistoryAction = withActionInstrumentation( + "adminProcessHistory", + async ({ + emailAddress, + historyId, + startHistoryId, + }: { + emailAddress: string; + historyId?: number; + startHistoryId?: number; + }) => { + const session = await auth(); + const userId = session?.user.id; + if (!userId) return { error: "Not logged in" }; + if (!isAdmin(session.user.email)) return { error: "Not admin" }; - console.log(`Processing history for ${emailAddress}`); + console.log(`Processing history for ${emailAddress}`); - await processHistoryForUser( - { - emailAddress, - historyId: historyId ? historyId : 0, - }, - { - startHistoryId: startHistoryId ? startHistoryId.toString() : undefined, - }, - ); -} + await processHistoryForUser( + { + emailAddress, + historyId: historyId ? historyId : 0, + }, + { + startHistoryId: startHistoryId ? startHistoryId.toString() : undefined, + }, + ); + }, +); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 17228cf460..b410984d06 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -57,6 +57,6 @@ export const categorizeAction = withActionInstrumentation( { email: u.email! }, ); - return res; + return { category: res?.category }; }, ); diff --git a/apps/web/utils/actions/error-messages.ts b/apps/web/utils/actions/error-messages.ts index c98a57d2a9..6c90d27e8b 100644 --- a/apps/web/utils/actions/error-messages.ts +++ b/apps/web/utils/actions/error-messages.ts @@ -2,12 +2,15 @@ import { revalidatePath } from "next/cache"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { ServerActionResponse } from "@/utils/error"; import { clearUserErrorMessages } from "@/utils/error-messages"; +import { withActionInstrumentation } from "@/utils/actions/middleware"; -export async function clearUserErrorMessagesAction(): Promise { - const session = await auth(); - if (!session?.user) return { error: "Not logged in" }; - await clearUserErrorMessages(session.user.id); - revalidatePath("/(app)", "layout"); -} +export const clearUserErrorMessagesAction = withActionInstrumentation( + "clearUserErrorMessages", + async () => { + const session = await auth(); + if (!session?.user) return { error: "Not logged in" }; + await clearUserErrorMessages(session.user.id); + revalidatePath("/(app)", "layout"); + }, +); diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index adc7e583da..6b74392c06 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -13,216 +13,227 @@ import { findNewsletters } from "@/utils/ai/group/find-newsletters"; import { findReceipts } from "@/utils/ai/group/find-receipts"; import { getGmailClient, getGmailAccessToken } from "@/utils/gmail/client"; import { GroupItemType } from "@prisma/client"; -import type { ServerActionResponse } from "@/utils/error"; import { NEWSLETTER_GROUP_ID, RECEIPT_GROUP_ID, } from "@/app/(app)/automation/create/examples"; import { GroupName } from "@/utils/config"; +import { withActionInstrumentation } from "@/utils/actions/middleware"; + +export const createGroupAction = withActionInstrumentation( + "createGroup", + async (body: CreateGroupBody) => { + const { name, prompt } = createGroupBody.parse(body); + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + try { + await prisma.group.create({ + data: { name, prompt, userId: session.user.id }, + }); + + revalidatePath(`/automation`); + } catch (error) { + return { error: "Error creating group" }; + } + }, +); + +export const createPredefinedGroupAction = withActionInstrumentation( + "createPredefinedGroup", + async (groupId: string) => { + if (groupId === NEWSLETTER_GROUP_ID) { + return await createNewsletterGroupAction(); + } else if (groupId === RECEIPT_GROUP_ID) { + return await createReceiptGroupAction(); + } + + return { error: "Unknown group type" }; + }, +); + +export const createNewsletterGroupAction = withActionInstrumentation( + "createNewsletterGroup", + async () => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + const name = GroupName.NEWSLETTER; + const existingGroup = await prisma.group.findFirst({ + where: { name, userId: session.user.id }, + select: { id: true }, + }); + if (existingGroup) return { id: existingGroup.id }; + + const gmail = getGmailClient(session); + const token = await getGmailAccessToken(session); + if (!token.token) return { error: "No access token" }; + const newsletters = await findNewsletters(gmail, token.token); + + const group = await prisma.group.create({ + data: { + name, + userId: session.user.id, + items: { + create: newsletters.map((newsletter) => ({ + type: GroupItemType.FROM, + value: newsletter, + })), + }, + }, + }); + + revalidatePath(`/automation`); -export async function createGroupAction( - body: CreateGroupBody, -): Promise { - const { name, prompt } = createGroupBody.parse(body); - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + return { id: group.id }; + }, +); - try { - await prisma.group.create({ - data: { name, prompt, userId: session.user.id }, +export const createReceiptGroupAction = withActionInstrumentation( + "createReceiptGroup", + async () => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + const name = GroupName.RECEIPT; + const existingGroup = await prisma.group.findFirst({ + where: { name, userId: session.user.id }, + select: { id: true }, + }); + if (existingGroup) return { id: existingGroup.id }; + + const gmail = getGmailClient(session); + const token = await getGmailAccessToken(session); + if (!token.token) return { error: "No access token" }; + const receipts = await findReceipts(gmail, token.token); + + const group = await prisma.group.create({ + data: { + name, + userId: session.user.id, + items: { create: receipts }, + }, }); revalidatePath(`/automation`); - } catch (error) { - return { error: "Error creating group" }; - } -} - -export async function createPredefinedGroupAction( - groupId: string, -): Promise> { - if (groupId === NEWSLETTER_GROUP_ID) { - return await createNewsletterGroupAction(); - } else if (groupId === RECEIPT_GROUP_ID) { - return await createReceiptGroupAction(); - } - - return { error: "Unknown group type" }; -} - -export async function createNewsletterGroupAction(): Promise< - ServerActionResponse<{ id: string }> -> { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - const name = GroupName.NEWSLETTER; - const existingGroup = await prisma.group.findFirst({ - where: { name, userId: session.user.id }, - select: { id: true }, - }); - if (existingGroup) return { id: existingGroup.id }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - if (!token.token) return { error: "No access token" }; - const newsletters = await findNewsletters(gmail, token.token); - - const group = await prisma.group.create({ - data: { - name, - userId: session.user.id, - items: { - create: newsletters.map((newsletter) => ({ - type: GroupItemType.FROM, - value: newsletter, - })), - }, - }, - }); - - revalidatePath(`/automation`); - - return { id: group.id }; -} - -export async function createReceiptGroupAction(): Promise< - ServerActionResponse<{ id: string }> -> { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - const name = GroupName.RECEIPT; - const existingGroup = await prisma.group.findFirst({ - where: { name, userId: session.user.id }, - select: { id: true }, - }); - if (existingGroup) return { id: existingGroup.id }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - if (!token.token) return { error: "No access token" }; - const receipts = await findReceipts(gmail, token.token); - - const group = await prisma.group.create({ - data: { - name, - userId: session.user.id, - items: { create: receipts }, - }, - }); - - revalidatePath(`/automation`); - - return { id: group.id }; -} - -export async function regenerateNewsletterGroupAction( - groupId: string, -): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - const existingGroup = await prisma.group.findUnique({ - where: { id: groupId, userId: session.user.id }, - select: { items: { select: { id: true, type: true, value: true } } }, - }); - if (!existingGroup) return { error: "Group not found" }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - if (!token.token) return { error: "No access token" }; - const newsletters = await findNewsletters(gmail, token.token); - - const newItems = newsletters.filter( - (newItem) => - !existingGroup.items.find( - (item) => item.value === newItem && item.type === GroupItemType.FROM, - ), - ); - - await prisma.groupItem.createMany({ - data: newItems.map((item) => ({ - type: GroupItemType.FROM, - value: item, - groupId, - })), - }); - - revalidatePath(`/automation`); -} - -export async function regenerateReceiptGroupAction( - groupId: string, -): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - const existingGroup = await prisma.group.findUnique({ - where: { id: groupId, userId: session.user.id }, - select: { items: { select: { id: true, type: true, value: true } } }, - }); - if (!existingGroup) return { error: "Group not found" }; - - const gmail = getGmailClient(session); - const token = await getGmailAccessToken(session); - if (!token.token) return { error: "No access token" }; - const receipts = await findReceipts(gmail, token.token); - - const newItems = receipts.filter( - (newItem) => - !existingGroup.items.find( - (item) => item.value === newItem.value && item.type === newItem.type, - ), - ); - - await prisma.groupItem.createMany({ - data: newItems.map((item) => ({ - type: GroupItemType.FROM, - value: item.value, - groupId, - })), - }); - - revalidatePath(`/automation`); -} - -export async function deleteGroupAction( - id: string, -): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - await prisma.group.delete({ where: { id, userId: session.user.id } }); - - revalidatePath(`/automation`); -} - -export async function addGroupItemAction( - body: AddGroupItemBody, -): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - const group = await prisma.group.findUnique({ where: { id: body.groupId } }); - if (!group) return { error: "Group not found" }; - if (group.userId !== session.user.id) - return { error: "You don't have permission to add items to this group" }; - - await prisma.groupItem.create({ data: addGroupItemBody.parse(body) }); - - revalidatePath(`/automation`); -} - -export async function deleteGroupItemAction( - id: string, -): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; - - await prisma.groupItem.delete({ - where: { id, group: { userId: session.user.id } }, - }); - - revalidatePath(`/automation`); -} + + return { id: group.id }; + }, +); + +export const regenerateNewsletterGroupAction = withActionInstrumentation( + "regenerateNewsletterGroup", + async (groupId: string) => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + const existingGroup = await prisma.group.findUnique({ + where: { id: groupId, userId: session.user.id }, + select: { items: { select: { id: true, type: true, value: true } } }, + }); + if (!existingGroup) return { error: "Group not found" }; + + const gmail = getGmailClient(session); + const token = await getGmailAccessToken(session); + if (!token.token) return { error: "No access token" }; + const newsletters = await findNewsletters(gmail, token.token); + + const newItems = newsletters.filter( + (newItem) => + !existingGroup.items.find( + (item) => item.value === newItem && item.type === GroupItemType.FROM, + ), + ); + + await prisma.groupItem.createMany({ + data: newItems.map((item) => ({ + type: GroupItemType.FROM, + value: item, + groupId, + })), + }); + + revalidatePath(`/automation`); + }, +); + +export const regenerateReceiptGroupAction = withActionInstrumentation( + "regenerateReceiptGroup", + async (groupId: string) => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + const existingGroup = await prisma.group.findUnique({ + where: { id: groupId, userId: session.user.id }, + select: { items: { select: { id: true, type: true, value: true } } }, + }); + if (!existingGroup) return { error: "Group not found" }; + + const gmail = getGmailClient(session); + const token = await getGmailAccessToken(session); + if (!token.token) return { error: "No access token" }; + const receipts = await findReceipts(gmail, token.token); + + const newItems = receipts.filter( + (newItem) => + !existingGroup.items.find( + (item) => item.value === newItem.value && item.type === newItem.type, + ), + ); + + await prisma.groupItem.createMany({ + data: newItems.map((item) => ({ + type: GroupItemType.FROM, + value: item.value, + groupId, + })), + }); + + revalidatePath(`/automation`); + }, +); + +export const deleteGroupAction = withActionInstrumentation( + "deleteGroup", + async (id: string) => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + await prisma.group.delete({ where: { id, userId: session.user.id } }); + + revalidatePath(`/automation`); + }, +); + +export const addGroupItemAction = withActionInstrumentation( + "addGroupItem", + async (body: AddGroupItemBody) => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + const group = await prisma.group.findUnique({ + where: { id: body.groupId }, + }); + if (!group) return { error: "Group not found" }; + if (group.userId !== session.user.id) + return { error: "You don't have permission to add items to this group" }; + + await prisma.groupItem.create({ data: addGroupItemBody.parse(body) }); + + revalidatePath(`/automation`); + }, +); + +export const deleteGroupItemAction = withActionInstrumentation( + "deleteGroupItem", + async (id: string) => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + await prisma.groupItem.delete({ + where: { id, group: { userId: session.user.id } }, + }); + + revalidatePath(`/automation`); + }, +); diff --git a/apps/web/utils/actions/helpers.ts b/apps/web/utils/actions/helpers.ts index 843ceb4ed3..164ddd7ed2 100644 --- a/apps/web/utils/actions/helpers.ts +++ b/apps/web/utils/actions/helpers.ts @@ -1,24 +1,9 @@ import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import { captureException } from "@/utils/error"; import { getGmailClient } from "@/utils/gmail/client"; -// do not return functions to the client or we'll get an error -export const isStatusOk = (status: number) => status >= 200 && status < 300; - export async function getSessionAndGmailClient() { const session = await auth(); if (!session?.user.email) return { error: "Not logged in" }; const gmail = getGmailClient(session); return { gmail, user: { id: session.user.id, email: session.user.email } }; } - -export function handleError( - actionName: string, - error: unknown, - message: string, - userEmail: string, -) { - captureException(error, { extra: { message, actionName } }, userEmail); - console.error(message, error); - return { error: message }; -} diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index 74f9740f81..7f75684224 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -17,205 +17,204 @@ import { createFilter, deleteFilter, } from "@/utils/gmail/filter"; -import type { ServerActionResponse } from "@/utils/error"; -import type { gmail_v1 } from "@googleapis/gmail"; -import { - getSessionAndGmailClient, - isStatusOk, - handleError, -} from "@/utils/actions/helpers"; - -async function executeGmailAction( - actionName: string, - action: ( - gmail: gmail_v1.Gmail, - user: { id: string; email: string }, - ) => Promise, - errorMessage: string, - onError?: (error: unknown) => boolean, // returns true if error was handled -): Promise> { - const { gmail, user, error } = await getSessionAndGmailClient(); - if (error) return { error }; - if (!gmail) return { error: "Could not load Gmail" }; - - try { - const res = await action(gmail, user); - return !isStatusOk(res.status) - ? handleError(actionName, res, errorMessage, user.email) - : undefined; - } catch (error) { - if (onError?.(error)) return; - return handleError(actionName, error, errorMessage, user.email); - } -} - -export async function archiveThreadAction( - threadId: string, -): Promise { - return executeGmailAction( - "archiveThread", - async (gmail, user) => - archiveThread({ - gmail, - threadId, - ownerEmail: user.email, - actionSource: "user", - }), - "Failed to archive thread", - ); -} - -export async function trashThreadAction( - threadId: string, -): Promise { - return executeGmailAction( - "trashThread", - async (gmail, user) => - trashThread({ - gmail, - threadId, - ownerEmail: user.email, - actionSource: "user", - }), - "Failed to delete thread", - ); -} - -export async function trashMessageAction( - messageId: string, -): Promise { - return executeGmailAction( - "trashMessage", - async (gmail) => trashMessage({ gmail, messageId }), - "Failed to delete message", - ); -} - -export async function markReadThreadAction( - threadId: string, - read: boolean, -): Promise { - return executeGmailAction( - "markReadThread", - async (gmail) => markReadThread({ gmail, threadId, read }), - "Failed to mark thread as read", - ); -} - -export async function markImportantMessageAction( - messageId: string, - important: boolean, -): Promise { - return executeGmailAction( - "markImportantMessage", - async (gmail) => markImportantMessage({ gmail, messageId, important }), - "Failed to mark message as important", - ); -} - -export async function markSpamThreadAction( - threadId: string, -): Promise { - return executeGmailAction( - "markSpamThread", - async (gmail) => markSpam({ gmail, threadId }), - "Failed to mark thread as spam", - ); -} - -export async function createAutoArchiveFilterAction( - from: string, - gmailLabelId?: string, -): Promise { - return executeGmailAction( - "createAutoArchiveFilter", - async (gmail) => createAutoArchiveFilter({ gmail, from, gmailLabelId }), - "Failed to create auto archive filter", - (error) => { - const errorMessage = (error as any)?.errors?.[0]?.message; - if (errorMessage === "Filter already exists") return true; - return false; - }, - ); -} - -export async function createFilterAction( - from: string, - gmailLabelId: string, -): Promise { - return executeGmailAction( - "createFilter", - async (gmail) => createFilter({ gmail, from, addLabelIds: [gmailLabelId] }), - "Failed to create filter", - ); -} - -export async function deleteFilterAction( - id: string, -): Promise { - return executeGmailAction( - "deleteFilter", - async (gmail) => deleteFilter({ gmail, id }), - "Failed to delete filter", - ); -} - -export async function createLabelAction(options: { - name: string; - description?: string; -}): Promise { - try { +import { getSessionAndGmailClient } from "@/utils/actions/helpers"; +import { withActionInstrumentation } from "@/utils/actions/middleware"; + +// do not return functions to the client or we'll get an error +const isStatusOk = (status: number) => status >= 200 && status < 300; + +export const archiveThreadAction = withActionInstrumentation( + "archiveThread", + async (threadId: string) => { + const { gmail, user, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await archiveThread({ + gmail, + threadId, + ownerEmail: user.email, + actionSource: "user", + }); + + if (!isStatusOk(res.status)) return { error: "Failed to archive thread" }; + }, +); + +export const trashThreadAction = withActionInstrumentation( + "trashThread", + async (threadId: string) => { + const { gmail, user, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await trashThread({ + gmail, + threadId, + ownerEmail: user.email, + actionSource: "user", + }); + + if (!isStatusOk(res.status)) return { error: "Failed to delete thread" }; + }, +); + +export const trashMessageAction = withActionInstrumentation( + "trashMessage", + async (messageId: string) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await trashMessage({ gmail, messageId }); + + if (!isStatusOk(res.status)) return { error: "Failed to delete message" }; + }, +); + +export const markReadThreadAction = withActionInstrumentation( + "markReadThread", + async (threadId: string, read: boolean) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await markReadThread({ gmail, threadId, read }); + + if (!isStatusOk(res.status)) + return { error: "Failed to mark thread as read" }; + }, +); + +export const markImportantMessageAction = withActionInstrumentation( + "markImportantMessage", + async (messageId: string, important: boolean) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await markImportantMessage({ gmail, messageId, important }); + + if (!isStatusOk(res.status)) + return { error: "Failed to mark message as important" }; + }, +); + +export const markSpamThreadAction = withActionInstrumentation( + "markSpamThread", + async (threadId: string) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await markSpam({ gmail, threadId }); + + if (!isStatusOk(res.status)) + return { error: "Failed to mark thread as spam" }; + }, +); + +export const createAutoArchiveFilterAction = withActionInstrumentation( + "createAutoArchiveFilter", + async (from: string, gmailLabelId?: string) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await createAutoArchiveFilter({ gmail, from, gmailLabelId }); + + if (!isStatusOk(res.status)) + return { error: "Failed to create auto archive filter" }; + }, +); + +export const createFilterAction = withActionInstrumentation( + "createFilter", + async (from: string, gmailLabelId: string) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await createFilter({ + gmail, + from, + addLabelIds: [gmailLabelId], + }); + + if (!isStatusOk(res.status)) return { error: "Failed to create filter" }; + + return res; + }, +); + +export const deleteFilterAction = withActionInstrumentation( + "deleteFilter", + async (id: string) => { + const { gmail, error } = await getSessionAndGmailClient(); + if (error) return { error }; + if (!gmail) return { error: "Could not load Gmail" }; + + const res = await deleteFilter({ gmail, id }); + + if (!isStatusOk(res.status)) return { error: "Failed to delete filter" }; + }, +); + +export const createLabelAction = withActionInstrumentation( + "createLabel", + async (options: { name: string; description?: string }) => { const label = await createLabel(options); return label; - } catch (error: any) { - return { error: error.message }; - } -} - -export async function updateLabelsAction( - labels: Pick[], -): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const userId = session.user.id; - - const enabledLabels = labels.filter((label) => label.enabled); - const disabledLabels = labels.filter((label) => !label.enabled); - - await prisma.$transaction([ - ...enabledLabels.map((label) => { - const { name, description, enabled, gmailLabelId } = label; - - return prisma.label.upsert({ - where: { name_userId: { name, userId } }, - create: { - gmailLabelId, - name, - description, - enabled, - user: { connect: { id: userId } }, - }, - update: { - name, - description, - enabled, + }, +); + +export const updateLabelsAction = withActionInstrumentation( + "updateLabels", + async ( + labels: Pick[], + ) => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; + + const userId = session.user.id; + + const enabledLabels = labels.filter((label) => label.enabled); + const disabledLabels = labels.filter((label) => !label.enabled); + + await prisma.$transaction([ + ...enabledLabels.map((label) => { + const { name, description, enabled, gmailLabelId } = label; + + return prisma.label.upsert({ + where: { name_userId: { name, userId } }, + create: { + gmailLabelId, + name, + description, + enabled, + user: { connect: { id: userId } }, + }, + update: { + name, + description, + enabled, + }, + }); + }), + prisma.label.deleteMany({ + where: { + userId, + name: { in: disabledLabels.map((label) => label.name) }, }, - }); - }), - prisma.label.deleteMany({ - where: { - userId, - name: { in: disabledLabels.map((label) => label.name) }, - }, - }), - ]); - - await saveUserLabels({ - email: session.user.email, - labels: enabledLabels.map((l) => ({ - ...l, - id: l.gmailLabelId, - })), - }); -} + }), + ]); + + await saveUserLabels({ + email: session.user.email, + labels: enabledLabels.map((l) => ({ + ...l, + id: l.gmailLabelId, + })), + }); + }, +); diff --git a/apps/web/utils/actions/middleware.ts b/apps/web/utils/actions/middleware.ts index d2b364d94f..38a21d6616 100644 --- a/apps/web/utils/actions/middleware.ts +++ b/apps/web/utils/actions/middleware.ts @@ -1,17 +1,62 @@ import { withServerActionInstrumentation } from "@sentry/nextjs"; -import { type ServerActionResponse } from "@/utils/error"; +import { ActionError, type ServerActionResponse } from "@/utils/error"; -export function withActionInstrumentation( +// Utility type to ensure we're dealing with object types only +type EnsureObject = T extends object ? T : never; + +/** + * Wraps an action in instrumentation to capture errors and send them to Sentry + * We also make sure to always return an object, so that if the client doesn't get a response, + * it's because there was an error, and likely it was a timeout because otherwise it would receive the response. + * We also handle thrown errors and convert them to an object with an error message. + * Actions are expected to return { error: string } objects when they fail, but if an error is thrown, we handle it here. + * NOTE: future updates: we can do more when errors are thrown like we do with `withError` middleware for API routes. + */ +export function withActionInstrumentation< + Args extends any[], + Result extends object | undefined = undefined, + Err = {}, +>( name: string, - action: (...args: Args) => Promise>, + action: (...args: Args) => Promise>, options?: { recordResponse?: boolean }, ) { - return async (...args: Args): Promise> => - withServerActionInstrumentation( - name, - { - recordResponse: options?.recordResponse ?? true, + return async ( + ...args: Args + ): Promise< + ServerActionResponse< + // If Result is undefined, return {}, otherwise ensure it's an object + (Result extends undefined ? {} : EnsureObject) & { + success: boolean; }, - async () => await action(...args), - ); + Err + > + > => { + try { + const result = await withServerActionInstrumentation( + name, + { + recordResponse: options?.recordResponse ?? true, + }, + async () => { + const res = await action(...args); + + // We return success: true to indicate that the action completed successfully + // If there's a timeout, then this won't be called, so the client can see there's been an error + return { + success: true, + ...(res as Result extends undefined ? {} : EnsureObject), + }; + }, + ); + + return result; + } catch (error) { + // error is already captured by Sentry in `withServerActionInstrumentation` + return { + error: "An error occurred", + success: false, + } as ActionError; + } + }; } diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index 661871fd8c..f3e838f385 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -15,59 +15,64 @@ import { } from "@/app/api/lemon-squeezy/api"; import { isAdmin } from "@/utils/admin"; import { PremiumTier } from "@prisma/client"; -import type { ServerActionResponse } from "@/utils/error"; - -export async function decrementUnsubscribeCreditAction(): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { - premium: { - select: { - id: true, - unsubscribeCredits: true, - unsubscribeMonth: true, - lemonSqueezyRenewsAt: true, +import { withActionInstrumentation } from "@/utils/actions/middleware"; + +export const decrementUnsubscribeCreditAction = withActionInstrumentation( + "decrementUnsubscribeCredit", + async () => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + premium: { + select: { + id: true, + unsubscribeCredits: true, + unsubscribeMonth: true, + lemonSqueezyRenewsAt: true, + }, }, }, - }, - }); + }); - if (!user) return { error: "User not found" }; + if (!user) return { error: "User not found" }; - const isUserPremium = isPremium(user.premium?.lemonSqueezyRenewsAt || null); - if (isUserPremium) return; + const isUserPremium = isPremium(user.premium?.lemonSqueezyRenewsAt || null); + if (isUserPremium) return; - const currentMonth = new Date().getMonth() + 1; + const currentMonth = new Date().getMonth() + 1; - // create premium row for user if it doesn't already exist - const premium = user.premium || (await createPremiumForUser(session.user.id)); + // create premium row for user if it doesn't already exist + const premium = + user.premium || (await createPremiumForUser(session.user.id)); - if ( - !premium?.unsubscribeMonth || - premium?.unsubscribeMonth !== currentMonth - ) { - // reset the monthly credits - await prisma.premium.update({ - where: { id: premium.id }, - data: { - // reset and use a credit - unsubscribeCredits: env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS - 1, - unsubscribeMonth: currentMonth, - }, - }); - } else { - if (!premium?.unsubscribeCredits || premium.unsubscribeCredits <= 0) return; + if ( + !premium?.unsubscribeMonth || + premium?.unsubscribeMonth !== currentMonth + ) { + // reset the monthly credits + await prisma.premium.update({ + where: { id: premium.id }, + data: { + // reset and use a credit + unsubscribeCredits: env.NEXT_PUBLIC_FREE_UNSUBSCRIBE_CREDITS - 1, + unsubscribeMonth: currentMonth, + }, + }); + } else { + if (!premium?.unsubscribeCredits || premium.unsubscribeCredits <= 0) + return; - // decrement the monthly credits - await prisma.premium.update({ - where: { id: premium.id }, - data: { unsubscribeCredits: { decrement: 1 } }, - }); - } -} + // decrement the monthly credits + await prisma.premium.update({ + where: { id: premium.id }, + data: { unsubscribeCredits: { decrement: 1 } }, + }); + } + }, +); export async function updateMultiAccountPremiumAction( emails: string[], @@ -177,138 +182,152 @@ async function createPremiumForUser(userId: string) { }); } -export async function activateLicenseKeyAction( - licenseKey: string, -): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - - const lemonSqueezyLicense = await activateLemonLicenseKey( - licenseKey, - `License for ${session.user.email}`, - ); +export const activateLicenseKeyAction = withActionInstrumentation( + "activateLicenseKey", + async (licenseKey: string) => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; + + const lemonSqueezyLicense = await activateLemonLicenseKey( + licenseKey, + `License for ${session.user.email}`, + ); + + if (lemonSqueezyLicense.error) { + return { + error: lemonSqueezyLicense.data?.error || "Error activating license", + }; + } - if (lemonSqueezyLicense.error) { - return { - error: lemonSqueezyLicense.data?.error || "Error activating license", + const seats = { + [env.LICENSE_1_SEAT_VARIANT_ID || ""]: 1, + [env.LICENSE_3_SEAT_VARIANT_ID || ""]: 3, + [env.LICENSE_5_SEAT_VARIANT_ID || ""]: 5, + [env.LICENSE_10_SEAT_VARIANT_ID || ""]: 10, + [env.LICENSE_25_SEAT_VARIANT_ID || ""]: 25, }; - } - - const seats = { - [env.LICENSE_1_SEAT_VARIANT_ID || ""]: 1, - [env.LICENSE_3_SEAT_VARIANT_ID || ""]: 3, - [env.LICENSE_5_SEAT_VARIANT_ID || ""]: 5, - [env.LICENSE_10_SEAT_VARIANT_ID || ""]: 10, - [env.LICENSE_25_SEAT_VARIANT_ID || ""]: 25, - }; - - await upgradeToPremium({ - userId: session.user.id, - tier: PremiumTier.LIFETIME, - lemonLicenseKey: licenseKey, - lemonLicenseInstanceId: lemonSqueezyLicense.data?.instance?.id, - emailAccountsAccess: seats[lemonSqueezyLicense.data?.meta.variant_id || ""], - lemonSqueezyCustomerId: lemonSqueezyLicense.data?.meta.customer_id || null, - lemonSqueezyOrderId: lemonSqueezyLicense.data?.meta.order_id || null, - lemonSqueezyProductId: lemonSqueezyLicense.data?.meta.product_id || null, - lemonSqueezyVariantId: lemonSqueezyLicense.data?.meta.variant_id || null, - lemonSqueezySubscriptionId: null, - lemonSqueezySubscriptionItemId: null, - lemonSqueezyRenewsAt: null, - }); -} - -export async function changePremiumStatusAction( - options: ChangePremiumStatusOptions, -): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; - if (!isAdmin(session.user.email)) return { error: "Not admin" }; - - const userToUpgrade = await prisma.user.findUnique({ - where: { email: options.email }, - select: { id: true, premiumId: true }, - }); - - if (!userToUpgrade) return { error: "User not found" }; - - const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; - - let lemonSqueezySubscriptionId: number | null = null; - let lemonSqueezySubscriptionItemId: number | null = null; - let lemonSqueezyOrderId: number | null = null; - let lemonSqueezyProductId: number | null = null; - let lemonSqueezyVariantId: number | null = null; - - if (options.upgrade) { - if (options.lemonSqueezyCustomerId) { - const lemonCustomer = await getLemonCustomer( - options.lemonSqueezyCustomerId.toString(), - ); - if (!lemonCustomer.data) return { error: "Lemon customer not found" }; - const subscription = lemonCustomer.data.included?.find( - (i) => i.type === "subscriptions", - ); - if (!subscription) return { error: "Subscription not found" }; - lemonSqueezySubscriptionId = Number.parseInt(subscription.id); - const attributes = subscription.attributes as any; - lemonSqueezyOrderId = Number.parseInt(attributes.order_id); - lemonSqueezyProductId = Number.parseInt(attributes.product_id); - lemonSqueezyVariantId = Number.parseInt(attributes.variant_id); - lemonSqueezySubscriptionItemId = attributes.first_subscription_item.id - ? Number.parseInt(attributes.first_subscription_item.id) - : null; - } await upgradeToPremium({ - userId: userToUpgrade.id, - tier: options.period, - lemonSqueezyCustomerId: options.lemonSqueezyCustomerId || null, - lemonSqueezySubscriptionId, - lemonSqueezySubscriptionItemId, - lemonSqueezyOrderId, - lemonSqueezyProductId, - lemonSqueezyVariantId, - lemonSqueezyRenewsAt: - options.period === PremiumTier.PRO_ANNUALLY || - options.period === PremiumTier.BUSINESS_ANNUALLY || - options.period === PremiumTier.BASIC_ANNUALLY - ? new Date(+new Date() + ONE_MONTH * 12) - : options.period === PremiumTier.PRO_MONTHLY || - options.period === PremiumTier.BUSINESS_MONTHLY || - options.period === PremiumTier.BASIC_MONTHLY || - options.period === PremiumTier.COPILOT_MONTHLY - ? new Date(+new Date() + ONE_MONTH) - : null, - emailAccountsAccess: options.emailAccountsAccess, + userId: session.user.id, + tier: PremiumTier.LIFETIME, + lemonLicenseKey: licenseKey, + lemonLicenseInstanceId: lemonSqueezyLicense.data?.instance?.id, + emailAccountsAccess: + seats[lemonSqueezyLicense.data?.meta.variant_id || ""], + lemonSqueezyCustomerId: + lemonSqueezyLicense.data?.meta.customer_id || null, + lemonSqueezyOrderId: lemonSqueezyLicense.data?.meta.order_id || null, + lemonSqueezyProductId: lemonSqueezyLicense.data?.meta.product_id || null, + lemonSqueezyVariantId: lemonSqueezyLicense.data?.meta.variant_id || null, + lemonSqueezySubscriptionId: null, + lemonSqueezySubscriptionItemId: null, + lemonSqueezyRenewsAt: null, + }); + }, +); + +export const changePremiumStatusAction = withActionInstrumentation( + "changePremiumStatus", + async (options: ChangePremiumStatusOptions) => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; + if (!isAdmin(session.user.email)) return { error: "Not admin" }; + + const userToUpgrade = await prisma.user.findUnique({ + where: { email: options.email }, + select: { id: true, premiumId: true }, }); - } else if (userToUpgrade) { - if (userToUpgrade.premiumId) { - await cancelPremium({ - premiumId: userToUpgrade.premiumId, - lemonSqueezyEndsAt: new Date(), - }); - } else { - return { error: "User not premium." }; - } - } -} -export async function claimPremiumAdminAction(): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; + if (!userToUpgrade) return { error: "User not found" }; + + const ONE_MONTH = 1000 * 60 * 60 * 24 * 30; + + let lemonSqueezySubscriptionId: number | null = null; + let lemonSqueezySubscriptionItemId: number | null = null; + let lemonSqueezyOrderId: number | null = null; + let lemonSqueezyProductId: number | null = null; + let lemonSqueezyVariantId: number | null = null; + + if (options.upgrade) { + if (options.lemonSqueezyCustomerId) { + const lemonCustomer = await getLemonCustomer( + options.lemonSqueezyCustomerId.toString(), + ); + if (!lemonCustomer.data) return { error: "Lemon customer not found" }; + const subscription = lemonCustomer.data.included?.find( + (i) => i.type === "subscriptions", + ); + if (!subscription) return { error: "Subscription not found" }; + lemonSqueezySubscriptionId = Number.parseInt(subscription.id); + const attributes = subscription.attributes as any; + lemonSqueezyOrderId = Number.parseInt(attributes.order_id); + lemonSqueezyProductId = Number.parseInt(attributes.product_id); + lemonSqueezyVariantId = Number.parseInt(attributes.variant_id); + lemonSqueezySubscriptionItemId = attributes.first_subscription_item.id + ? Number.parseInt(attributes.first_subscription_item.id) + : null; + } - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { premium: { select: { id: true, admins: true } } }, - }); + const getRenewsAt = (period: PremiumTier): Date | null => { + const now = new Date(); + switch (period) { + case PremiumTier.PRO_ANNUALLY: + case PremiumTier.BUSINESS_ANNUALLY: + case PremiumTier.BASIC_ANNUALLY: + return new Date(now.getTime() + ONE_MONTH * 12); + case PremiumTier.PRO_MONTHLY: + case PremiumTier.BUSINESS_MONTHLY: + case PremiumTier.BASIC_MONTHLY: + case PremiumTier.COPILOT_MONTHLY: + return new Date(now.getTime() + ONE_MONTH); + default: + return null; + } + }; + + await upgradeToPremium({ + userId: userToUpgrade.id, + tier: options.period, + lemonSqueezyCustomerId: options.lemonSqueezyCustomerId || null, + lemonSqueezySubscriptionId, + lemonSqueezySubscriptionItemId, + lemonSqueezyOrderId, + lemonSqueezyProductId, + lemonSqueezyVariantId, + lemonSqueezyRenewsAt: getRenewsAt(options.period), + emailAccountsAccess: options.emailAccountsAccess, + }); + } else if (userToUpgrade) { + if (userToUpgrade.premiumId) { + await cancelPremium({ + premiumId: userToUpgrade.premiumId, + lemonSqueezyEndsAt: new Date(), + }); + } else { + return { error: "User not premium." }; + } + } + }, +); + +export const claimPremiumAdminAction = withActionInstrumentation( + "claimPremiumAdmin", + async () => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { premium: { select: { id: true, admins: true } } }, + }); - if (!user) return { error: "User not found" }; - if (!user.premium?.id) return { error: "User does not have a premium" }; - if (user.premium?.admins.length) return { error: "Already has admin" }; + if (!user) return { error: "User not found" }; + if (!user.premium?.id) return { error: "User does not have a premium" }; + if (user.premium?.admins.length) return { error: "Already has admin" }; - await prisma.premium.update({ - where: { id: user.premium.id }, - data: { admins: { connect: { id: session.user.id } } }, - }); -} + await prisma.premium.update({ + where: { id: user.premium.id }, + data: { admins: { connect: { id: session.user.id } } }, + }); + }, +); diff --git a/apps/web/utils/actions/unsubscriber.ts b/apps/web/utils/actions/unsubscriber.ts index 1daad7eef0..d09bb24e3d 100644 --- a/apps/web/utils/actions/unsubscriber.ts +++ b/apps/web/utils/actions/unsubscriber.ts @@ -1,29 +1,32 @@ "use server"; import { auth } from "@/app/api/auth/[...nextauth]/auth"; -import type { ServerActionResponse } from "@/utils/error"; import prisma from "@/utils/prisma"; import type { NewsletterStatus } from "@prisma/client"; +import { withActionInstrumentation } from "@/utils/actions/middleware"; -export async function setNewsletterStatusAction(options: { - newsletterEmail: string; - status: NewsletterStatus | null; -}): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; +export const setNewsletterStatusAction = withActionInstrumentation( + "setNewsletterStatus", + async (options: { + newsletterEmail: string; + status: NewsletterStatus | null; + }) => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; - return await prisma.newsletter.upsert({ - where: { - email_userId: { + return await prisma.newsletter.upsert({ + where: { + email_userId: { + email: options.newsletterEmail, + userId: session.user.id, + }, + }, + create: { + status: options.status, email: options.newsletterEmail, - userId: session.user.id, + user: { connect: { id: session.user.id } }, }, - }, - create: { - status: options.status, - email: options.newsletterEmail, - user: { connect: { id: session.user.id } }, - }, - update: { status: options.status }, - }); -} + update: { status: options.status }, + }); + }, +); diff --git a/apps/web/utils/actions/user.ts b/apps/web/utils/actions/user.ts index 7648e8b005..7a7d7b14ff 100644 --- a/apps/web/utils/actions/user.ts +++ b/apps/web/utils/actions/user.ts @@ -10,66 +10,77 @@ import { deleteUserStats } from "@/utils/redis/stats"; import { deleteTinybirdEmails } from "@inboxzero/tinybird"; import { deleteTinybirdAiCalls } from "@inboxzero/tinybird-ai-analytics"; import { deletePosthogUser } from "@/utils/posthog"; -import { type ServerActionResponse, captureException } from "@/utils/error"; +import { captureException } from "@/utils/error"; +import { withActionInstrumentation } from "@/utils/actions/middleware"; const saveAboutBody = z.object({ about: z.string() }); export type SaveAboutBody = z.infer; -export async function saveAboutAction( - options: SaveAboutBody, -): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; +export const saveAboutAction = withActionInstrumentation( + "saveAbout", + async (options: SaveAboutBody) => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; - await prisma.user.update({ - where: { email: session.user.email }, - data: { about: options.about }, - }); -} + await prisma.user.update({ + where: { email: session.user.email }, + data: { about: options.about }, + }); + }, +); -export async function deleteAccountAction(): Promise { - const session = await auth(); - if (!session?.user.email) return { error: "Not logged in" }; +export const deleteAccountAction = withActionInstrumentation( + "deleteAccount", + async () => { + const session = await auth(); + if (!session?.user.email) return { error: "Not logged in" }; - try { - await Promise.allSettled([ - deleteUserLabels({ email: session.user.email }), - deleteInboxZeroLabels({ email: session.user.email }), - deleteUserStats({ email: session.user.email }), - deleteTinybirdEmails({ email: session.user.email }), - deleteTinybirdAiCalls({ userId: session.user.email }), - deletePosthogUser({ email: session.user.email }), - deleteLoopsContact(session.user.email), - deleteResendContact({ email: session.user.email }), - ]); - } catch (error) { - console.error("Error while deleting account: ", error); - captureException(error, undefined, session.user.email); - } + try { + await Promise.allSettled([ + deleteUserLabels({ email: session.user.email }), + deleteInboxZeroLabels({ email: session.user.email }), + deleteUserStats({ email: session.user.email }), + deleteTinybirdEmails({ email: session.user.email }), + deleteTinybirdAiCalls({ userId: session.user.email }), + deletePosthogUser({ email: session.user.email }), + deleteLoopsContact(session.user.email), + deleteResendContact({ email: session.user.email }), + ]); + } catch (error) { + console.error("Error while deleting account: ", error); + captureException(error, undefined, session.user.email); + } - await prisma.user.delete({ where: { email: session.user.email } }); -} + await prisma.user.delete({ where: { email: session.user.email } }); + }, +); -export async function completedOnboardingAction(): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; +export const completedOnboardingAction = withActionInstrumentation( + "completedOnboarding", + async () => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; - await prisma.user.update({ - where: { id: session.user.id }, - data: { completedOnboarding: true }, - }); -} + await prisma.user.update({ + where: { id: session.user.id }, + data: { completedOnboarding: true }, + }); + }, +); -export async function saveOnboardingAnswersAction(onboardingAnswers: { - surveyId?: string; - questions: any; - answers: Record; -}): Promise { - const session = await auth(); - if (!session?.user.id) return { error: "Not logged in" }; +export const saveOnboardingAnswersAction = withActionInstrumentation( + "saveOnboardingAnswers", + async (onboardingAnswers: { + surveyId?: string; + questions: any; + answers: Record; + }) => { + const session = await auth(); + if (!session?.user.id) return { error: "Not logged in" }; - await prisma.user.update({ - where: { id: session.user.id }, - data: { onboardingAnswers }, - }); -} + await prisma.user.update({ + where: { id: session.user.id }, + data: { onboardingAnswers }, + }); + }, +); diff --git a/apps/web/utils/error.ts b/apps/web/utils/error.ts index 772d0ca568..1f968abe01 100644 --- a/apps/web/utils/error.ts +++ b/apps/web/utils/error.ts @@ -31,11 +31,8 @@ export function captureException( sentryCaptureException(error, additionalInfo); } -export type ActionError = { error: string } & T; -export type ServerActionResponse = - | ActionError - | T - | undefined; +export type ActionError = { error: string } & E; +export type ServerActionResponse = ActionError | T; export function isActionError(error: any): error is ActionError { return error && typeof error === "object" && "error" in error && error.error; diff --git a/apps/web/utils/gmail/filter.ts b/apps/web/utils/gmail/filter.ts index 588f7d066e..657afc10a8 100644 --- a/apps/web/utils/gmail/filter.ts +++ b/apps/web/utils/gmail/filter.ts @@ -9,7 +9,7 @@ export async function createFilter(options: { }) { const { gmail, from, addLabelIds, removeLabelIds } = options; - return gmail.users.settings.filters.create({ + return await gmail.users.settings.filters.create({ userId: "me", requestBody: { criteria: { from }, @@ -21,19 +21,28 @@ export async function createFilter(options: { }); } -export async function createAutoArchiveFilter(options: { +export async function createAutoArchiveFilter({ + gmail, + from, + gmailLabelId, +}: { gmail: gmail_v1.Gmail; from: string; gmailLabelId?: string; }) { - const { gmail, from, gmailLabelId } = options; - - return createFilter({ - gmail, - from, - removeLabelIds: [INBOX_LABEL_ID], - addLabelIds: gmailLabelId ? [gmailLabelId] : undefined, - }); + try { + return await createFilter({ + gmail, + from, + removeLabelIds: [INBOX_LABEL_ID], + addLabelIds: gmailLabelId ? [gmailLabelId] : undefined, + }); + } catch (error) { + const errorMessage = (error as any)?.errors?.[0]?.message; + // if filter already exists, return 200 + if (errorMessage === "Filter already exists") return { status: 200 }; + throw error; + } } export async function deleteFilter(options: { diff --git a/apps/web/utils/server-action.ts b/apps/web/utils/server-action.ts index 7a52ff7a55..a5208607ad 100644 --- a/apps/web/utils/server-action.ts +++ b/apps/web/utils/server-action.ts @@ -1,7 +1,12 @@ "use client"; import { toastError, toastSuccess } from "@/components/Toast"; -import { type ServerActionResponse, isActionError } from "@/utils/error"; +import { + ActionError, + type ServerActionResponse, + captureException, + isActionError, +} from "@/utils/error"; export function handleActionResult( result: ServerActionResponse, @@ -13,3 +18,28 @@ export function handleActionResult( toastSuccess({ description: successMessage }); } } + +// NOTE: not in love with the indirection here +// Not sure I'll use across the app +export async function handleActionCall( + actionName: string, + actionFn: () => Promise>, +): Promise> { + let result: ServerActionResponse; + + try { + result = await actionFn(); + } catch (error) { + captureException(error, { extra: { actionName } }); + return { error: String(error) } as ActionError; + } + + if (isActionError(result)) return result; + + if (!result) { + captureException("The request did not complete", { extra: { actionName } }); + return { error: "The request did not complete" } as ActionError; + } + + return result; +}