diff --git a/apps/web/utils/actions/admin.ts b/apps/web/utils/actions/admin.ts index b24f5cd846..6cd8e5db77 100644 --- a/apps/web/utils/actions/admin.ts +++ b/apps/web/utils/actions/admin.ts @@ -6,6 +6,7 @@ import { createScopedLogger } from "@/utils/logger"; import { deleteUser } from "@/utils/user/delete"; import prisma from "@/utils/prisma"; import { adminActionClient } from "@/utils/actions/safe-action"; +import { SafeError } from "@/utils/error"; const logger = createScopedLogger("Admin Action"); @@ -40,14 +41,14 @@ export const adminDeleteAccountAction = adminActionClient .action(async ({ parsedInput: { email } }) => { try { const userToDelete = await prisma.user.findUnique({ where: { email } }); - if (!userToDelete) return { error: "User not found" }; + if (!userToDelete) throw new SafeError("User not found"); await deleteUser({ userId: userToDelete.id }); } catch (error) { logger.error("Failed to delete user", { email, error }); - return { - error: `Failed to delete user: ${error instanceof Error ? error.message : String(error)}`, - }; + throw new SafeError( + `Failed to delete user: ${error instanceof Error ? error.message : String(error)}`, + ); } return { success: "User deleted" }; diff --git a/apps/web/utils/actions/ai-rule.ts b/apps/web/utils/actions/ai-rule.ts index 4e63040f50..9049967eea 100644 --- a/apps/web/utils/actions/ai-rule.ts +++ b/apps/web/utils/actions/ai-rule.ts @@ -211,7 +211,7 @@ export const approvePlanAction = actionClient where: { id: executedRuleId }, include: { actionItems: true }, }); - if (!executedRule) return { error: "Item not found" }; + if (!executedRule) throw new SafeError("Plan not found"); await executeAct({ gmail, @@ -275,7 +275,7 @@ export const saveRulesPromptAction = actionClient if (!emailAccount) { logger.error("Email account not found"); - return { error: "Email account not found" }; + throw new SafeError("Email account not found"); } const oldPromptFile = emailAccount.rulesPrompt; @@ -507,7 +507,7 @@ export const generateRulesPromptAction = actionClient .action(async ({ ctx: { emailAccountId } }) => { const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); const gmail = await getGmailClientForEmail({ emailAccountId }); const lastSent = await getMessages(gmail, { @@ -564,7 +564,7 @@ export const generateRulesPromptAction = actionClient userLabels: labelsWithCounts.map((label) => label.label), }); - if (!result) return { error: "Error generating rules prompt" }; + if (!result) throw new SafeError("Error generating rules prompt"); return { rulesPrompt: result.join("\n\n") }; }); diff --git a/apps/web/utils/actions/categorize.ts b/apps/web/utils/actions/categorize.ts index 422ea73236..468ffbe9d7 100644 --- a/apps/web/utils/actions/categorize.ts +++ b/apps/web/utils/actions/categorize.ts @@ -253,7 +253,7 @@ async function upsertCategory({ } } catch (error) { if (isDuplicateError(error, "name")) - return { error: "Category with this name already exists" }; + throw new SafeError("Category with this name already exists"); throw error; } diff --git a/apps/web/utils/actions/generate-reply.ts b/apps/web/utils/actions/generate-reply.ts index 69eb88bd39..7b1cef1852 100644 --- a/apps/web/utils/actions/generate-reply.ts +++ b/apps/web/utils/actions/generate-reply.ts @@ -6,6 +6,7 @@ import { emailToContent } from "@/utils/mail"; import { getReply, saveReply } from "@/utils/redis/reply"; import { actionClient } from "@/utils/actions/safe-action"; import { getEmailAccountWithAi } from "@/utils/user/get"; +import { SafeError } from "@/utils/error"; export const generateNudgeReplyAction = actionClient .metadata({ name: "generateNudgeReply" }) @@ -17,11 +18,11 @@ export const generateNudgeReplyAction = actionClient }) => { const emailAccount = await getEmailAccountWithAi({ emailAccountId }); - if (!emailAccount) return { error: "User not found" }; + if (!emailAccount) throw new SafeError("User not found"); const lastMessage = inputMessages.at(-1); - if (!lastMessage) return { error: "No message provided" }; + if (!lastMessage) throw new SafeError("No message provided"); const reply = await getReply({ emailAccountId, diff --git a/apps/web/utils/actions/group.ts b/apps/web/utils/actions/group.ts index b70bdbf2a4..feaa6d3e20 100644 --- a/apps/web/utils/actions/group.ts +++ b/apps/web/utils/actions/group.ts @@ -8,6 +8,7 @@ import { } from "@/utils/actions/group.validation"; import { addGroupItem, deleteGroupItem } from "@/utils/group/group-item"; import { actionClient } from "@/utils/actions/safe-action"; +import { SafeError } from "@/utils/error"; export const createGroupAction = actionClient .metadata({ name: "createGroup" }) @@ -18,7 +19,7 @@ export const createGroupAction = actionClient select: { name: true, groupId: true }, }); if (rule?.groupId) return { groupId: rule.groupId }; - if (!rule) return { error: "Rule not found" }; + if (!rule) throw new SafeError("Rule not found"); const group = await prisma.group.create({ data: { @@ -44,11 +45,11 @@ export const addGroupItemAction = actionClient const group = await prisma.group.findUnique({ where: { id: groupId }, }); - if (!group) return { error: "Group not found" }; + if (!group) throw new SafeError("Learned patterns group not found"); if (group.emailAccountId !== emailAccountId) - return { - error: "You don't have permission to add items to this group", - }; + throw new SafeError( + "You don't have permission to add this learned pattern", + ); await addGroupItem({ groupId, type, value, exclude }); }, diff --git a/apps/web/utils/actions/mail.ts b/apps/web/utils/actions/mail.ts index 36b5ba3690..6582ba6584 100644 --- a/apps/web/utils/actions/mail.ts +++ b/apps/web/utils/actions/mail.ts @@ -19,6 +19,7 @@ import { import { sendEmailWithHtml, sendEmailBody } from "@/utils/gmail/mail"; import { actionClient } from "@/utils/actions/safe-action"; import { getGmailClientForEmail } from "@/utils/account"; +import { SafeError } from "@/utils/error"; // do not return functions to the client or we'll get an error const isStatusOk = (status: number) => status >= 200 && status < 300; @@ -41,7 +42,8 @@ export const archiveThreadAction = actionClient labelId, }); - if (!isStatusOk(res.status)) return { error: "Failed to archive thread" }; + if (!isStatusOk(res.status)) + throw new SafeError("Failed to archive thread"); }, ); @@ -62,7 +64,8 @@ export const trashThreadAction = actionClient actionSource: "user", }); - if (!isStatusOk(res.status)) return { error: "Failed to delete thread" }; + if (!isStatusOk(res.status)) + throw new SafeError("Failed to delete thread"); }, ); @@ -74,7 +77,7 @@ export const trashThreadAction = actionClient // const res = await trashMessage({ gmail, messageId }); -// if (!isStatusOk(res.status)) return { error: "Failed to delete message" }; +// if (!isStatusOk(res.status)) throw new SafeError("Failed to delete message"); // }); export const markReadThreadAction = actionClient @@ -87,7 +90,7 @@ export const markReadThreadAction = actionClient const res = await markReadThread({ gmail, threadId, read }); if (!isStatusOk(res.status)) - return { error: "Failed to mark thread as read" }; + throw new SafeError("Failed to mark thread as read"); }, ); @@ -104,7 +107,7 @@ export const markImportantMessageAction = actionClient const res = await markImportantMessage({ gmail, messageId, important }); if (!isStatusOk(res.status)) - return { error: "Failed to mark message as important" }; + throw new SafeError("Failed to mark message as important"); }, ); @@ -117,7 +120,7 @@ export const markSpamThreadAction = actionClient const res = await markSpam({ gmail, threadId }); if (!isStatusOk(res.status)) - return { error: "Failed to mark thread as spam" }; + throw new SafeError("Failed to mark thread as spam"); }); export const createAutoArchiveFilterAction = actionClient @@ -133,7 +136,7 @@ export const createAutoArchiveFilterAction = actionClient const res = await createAutoArchiveFilter({ gmail, from, gmailLabelId }); if (!isStatusOk(res.status)) - return { error: "Failed to create auto archive filter" }; + throw new SafeError("Failed to create auto archive filter"); }, ); @@ -153,7 +156,8 @@ export const createFilterAction = actionClient addLabelIds: [gmailLabelId], }); - if (!isStatusOk(res.status)) return { error: "Failed to create filter" }; + if (!isStatusOk(res.status)) + throw new SafeError("Failed to create filter"); return res; }, @@ -167,7 +171,7 @@ export const deleteFilterAction = actionClient const res = await deleteFilter({ gmail, id }); - if (!isStatusOk(res.status)) return { error: "Failed to delete filter" }; + if (!isStatusOk(res.status)) throw new SafeError("Failed to delete filter"); }); export const createLabelAction = actionClient diff --git a/apps/web/utils/actions/permissions.ts b/apps/web/utils/actions/permissions.ts index 3d1f04eb30..11f5418d3f 100644 --- a/apps/web/utils/actions/permissions.ts +++ b/apps/web/utils/actions/permissions.ts @@ -6,6 +6,7 @@ import { createScopedLogger } from "@/utils/logger"; import { actionClient, adminActionClient } from "@/utils/actions/safe-action"; import { getGmailAndAccessTokenForEmail } from "@/utils/account"; import prisma from "@/utils/prisma"; +import { SafeError } from "@/utils/error"; const logger = createScopedLogger("actions/permissions"); @@ -17,13 +18,13 @@ export const checkPermissionsAction = actionClient emailAccountId, }); - if (!accessToken) return { error: "No access token" }; + if (!accessToken) throw new SafeError("No access token"); const { hasAllPermissions, error } = await handleGmailPermissionsCheck({ accessToken, emailAccountId, }); - if (error) return { error }; + if (error) throw new SafeError(error); if (!hasAllPermissions) return { hasAllPermissions: false }; @@ -36,7 +37,7 @@ export const checkPermissionsAction = actionClient emailAccountId, error, }); - return { error: "Failed to check permissions" }; + throw new SafeError("Failed to check permissions"); } }); @@ -51,22 +52,22 @@ export const adminCheckPermissionsAction = adminActionClient id: true, }, }); - if (!emailAccount) return { error: "Email account not found" }; + if (!emailAccount) throw new SafeError("Email account not found"); const emailAccountId = emailAccount.id; const { accessToken } = await getGmailAndAccessTokenForEmail({ emailAccountId, }); - if (!accessToken) return { error: "No Gmail access token" }; + if (!accessToken) throw new SafeError("No Gmail access token"); const { hasAllPermissions, error } = await handleGmailPermissionsCheck({ accessToken, emailAccountId, }); - if (error) return { error }; + if (error) throw new SafeError(error); return { hasAllPermissions }; } catch (error) { logger.error("Admin failed to check permissions", { email, error }); - return { error: "Failed to check permissions" }; + throw new SafeError("Failed to check permissions"); } }); diff --git a/apps/web/utils/actions/premium.ts b/apps/web/utils/actions/premium.ts index 616285b1fd..eafbb4d2ee 100644 --- a/apps/web/utils/actions/premium.ts +++ b/apps/web/utils/actions/premium.ts @@ -9,13 +9,13 @@ import { env } from "@/env"; import { isAdminForPremium, isOnHigherTier, isPremium } from "@/utils/premium"; import { cancelPremiumLemon, + updateAccountSeatsForPremium, upgradeToPremiumLemon, } from "@/utils/premium/server"; import { changePremiumStatusSchema } from "@/app/(app)/admin/validation"; import { activateLemonLicenseKey, getLemonCustomer, - updateSubscriptionItemQuantity, } from "@/ee/billing/lemon/index"; import { PremiumTier } from "@prisma/client"; import { ONE_MONTH_MS, ONE_YEAR_MS } from "@/utils/date"; @@ -54,7 +54,7 @@ export const decrementUnsubscribeCreditAction = actionClientUser }, }); - if (!user) return { error: "User not found" }; + if (!user) throw new SafeError("User not found"); const isUserPremium = isPremium( user.premium?.lemonSqueezyRenewsAt || null, @@ -104,6 +104,7 @@ export const updateMultiAccountPremiumAction = actionClientUser id: true, tier: true, lemonSqueezySubscriptionItemId: true, + stripeSubscriptionItemId: true, emailAccountsAccess: true, admins: { select: { id: true } }, pendingInvites: true, @@ -112,10 +113,10 @@ export const updateMultiAccountPremiumAction = actionClientUser }, }); - if (!user) return { error: "User not found" }; + if (!user) throw new SafeError("User not found"); if (!isAdminForPremium(user.premium?.admins || [], userId)) - return { error: "Not admin" }; + throw new SafeError("Not admin"); // check all users exist const uniqueEmails = uniq(emails); @@ -131,25 +132,22 @@ export const updateMultiAccountPremiumAction = actionClientUser // make sure that the users being added to this plan are not on higher tiers already for (const userToAdd of otherUsers) { if (isOnHigherTier(userToAdd.premium?.tier, premium.tier)) { - return { - error: - "One of the users you are adding to your plan already has premium and cannot be added.", - }; + throw new SafeError( + "One of the users you are adding to your plan already has premium and cannot be added.", + ); } } if ((premium.emailAccountsAccess || 0) < uniqueEmails.length) { - // TODO lifetime users - if (!premium.lemonSqueezySubscriptionItemId) { - return { - error: `You must upgrade to premium before adding more users to your account. If you already have a premium plan, please contact support at ${env.NEXT_PUBLIC_SUPPORT_EMAIL}`, - }; + // Check if user has an active subscription + if ( + !premium.lemonSqueezySubscriptionItemId && + !premium.stripeSubscriptionItemId + ) { + throw new SafeError( + "You must upgrade to premium before adding more users to your account.", + ); } - - await updateSubscriptionItemQuantity({ - id: premium.lemonSqueezySubscriptionItemId, - quantity: uniqueEmails.length, - }); } // delete premium for other users when adding them to this premium plan @@ -173,14 +171,26 @@ export const updateMultiAccountPremiumAction = actionClientUser const nonExistingUsers = uniqueEmails.filter( (email) => !users.some((u) => u.email === email), ); - await prisma.premium.update({ + const updatedPremium = await prisma.premium.update({ where: { id: premium.id }, data: { pendingInvites: { set: uniq([...(premium.pendingInvites || []), ...nonExistingUsers]), }, }, + select: { + users: { select: { _count: { select: { emailAccounts: true } } } }, + pendingInvites: true, + }, }); + + // total seats = premium users + pending invites + const totalSeats = + sumBy(updatedPremium.users, (u) => u._count.emailAccounts) + + (updatedPremium.pendingInvites?.length || 0); + + // Update subscription quantity to reflect the actual total seats + await updateAccountSeatsForPremium(premium, totalSeats); }); // export const switchLemonPremiumPlanAction = actionClientUser @@ -196,9 +206,9 @@ export const updateMultiAccountPremiumAction = actionClientUser // }, // }); -// if (!user) return { error: "User not found" }; +// if (!user) throw new SafeError("User not found"); // if (!user.premium?.lemonSqueezySubscriptionId) -// return { error: "You do not have a premium subscription" }; +// throw new SafeError("You do not have a premium subscription"); // const variantId = getVariantId({ tier: premiumTier }); diff --git a/apps/web/utils/actions/rule.ts b/apps/web/utils/actions/rule.ts index 3b246e3fb5..490b435aa2 100644 --- a/apps/web/utils/actions/rule.ts +++ b/apps/web/utils/actions/rule.ts @@ -153,7 +153,7 @@ export const updateRuleAction = actionClient where: { id, emailAccountId }, include: { actions: true, categoryFilters: true, group: true }, }); - if (!currentRule) return { error: "Rule not found" }; + if (!currentRule) throw new SafeError("Rule not found"); const currentActions = currentRule.actions; @@ -253,12 +253,12 @@ export const updateRuleAction = actionClient return { rule: updatedRule }; } catch (error) { if (isDuplicateError(error, "name")) { - return { error: "Rule name already exists" }; + throw new SafeError("Rule name already exists"); } if (isDuplicateError(error, "groupId")) { - return { - error: "Group already has a rule. Please use the existing rule.", - }; + throw new SafeError( + "Group already has a rule. Please use the existing rule.", + ); } logger.error("Error updating rule", { error }); @@ -276,7 +276,7 @@ export const updateRuleInstructionsAction = actionClient where: { id, emailAccountId }, include: { actions: true, categoryFilters: true, group: true }, }); - if (!currentRule) return { error: "Rule not found" }; + if (!currentRule) throw new SafeError("Rule not found"); after(() => updateRuleInstructionsAndPromptFile({ @@ -301,7 +301,7 @@ export const updateRuleSettingsAction = actionClient const currentRule = await prisma.rule.findUnique({ where: { id, emailAccountId }, }); - if (!currentRule) return { error: "Rule not found" }; + if (!currentRule) throw new SafeError("Rule not found"); await prisma.rule.update({ where: { id, emailAccountId }, @@ -328,7 +328,7 @@ export const enableDraftRepliesAction = actionClient }, select: { id: true, actions: true }, }); - if (!rule) return { error: "Rule not found" }; + if (!rule) throw new SafeError("Rule not found"); if (enable) { await enableDraftReplies(rule); @@ -357,7 +357,7 @@ export const deleteRuleAction = actionClient }); if (!rule) return; // already deleted if (rule.emailAccountId !== emailAccountId) - return { error: "You don't have permission to delete this rule" }; + throw new SafeError("You don't have permission to delete this rule"); try { await deleteRule({ @@ -386,7 +386,7 @@ export const deleteRuleAction = actionClient }, }, }); - if (!emailAccount) return { error: "User not found" }; + if (!emailAccount) throw new SafeError("User not found"); if (!emailAccount.rulesPrompt) return; @@ -445,7 +445,7 @@ export const createRulesOnboardingAction = actionClient where: { id: emailAccountId }, select: { rulesPrompt: true }, }); - if (!emailAccount) return { error: "User not found" }; + if (!emailAccount) throw new SafeError("User not found"); const promises: Promise[] = []; diff --git a/apps/web/utils/actions/stats.ts b/apps/web/utils/actions/stats.ts index eea33129f4..8368bd82cb 100644 --- a/apps/web/utils/actions/stats.ts +++ b/apps/web/utils/actions/stats.ts @@ -12,6 +12,7 @@ import { GmailLabel } from "@/utils/gmail/label"; import { createScopedLogger } from "@/utils/logger"; import { internalDateToDate } from "@/utils/date"; import prisma from "@/utils/prisma"; +import { SafeError } from "@/utils/error"; const PAGE_SIZE = 20; // avoid setting too high because it will hit the rate limit // const PAUSE_AFTER_RATE_LIMIT = 10_000; @@ -27,7 +28,7 @@ export const loadEmailStatsAction = actionClient emailAccountId, }); - if (!accessToken) return { error: "Missing access token" }; + if (!accessToken) throw new SafeError("Missing access token"); await loadEmails( { diff --git a/apps/web/utils/premium/server.ts b/apps/web/utils/premium/server.ts index f231101cd0..1051247038 100644 --- a/apps/web/utils/premium/server.ts +++ b/apps/web/utils/premium/server.ts @@ -135,6 +135,16 @@ export async function updateAccountSeats({ userId }: { userId: string }) { // Count all email accounts for all users const totalSeats = sumBy(premium.users, (user) => user._count.emailAccounts); + await updateAccountSeatsForPremium(premium, totalSeats); +} + +export async function updateAccountSeatsForPremium( + premium: { + lemonSqueezySubscriptionItemId: number | null; + stripeSubscriptionItemId: string | null; + }, + totalSeats: number, +) { if (premium.stripeSubscriptionItemId) { await updateStripeSubscriptionItemQuantity({ subscriptionItemId: premium.stripeSubscriptionItemId, diff --git a/version.txt b/version.txt index 5421817638..06f8fe6766 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v1.3.18 \ No newline at end of file +v1.3.20 \ No newline at end of file