diff --git a/apps/web/app/api/google/linking/callback/route.ts b/apps/web/app/api/google/linking/callback/route.ts index 403205213c..089745e761 100644 --- a/apps/web/app/api/google/linking/callback/route.ts +++ b/apps/web/app/api/google/linking/callback/route.ts @@ -5,6 +5,7 @@ import { createScopedLogger } from "@/utils/logger"; import { getLinkingOAuth2Client } from "@/utils/gmail/client"; import { GOOGLE_LINKING_STATE_COOKIE_NAME } from "@/utils/gmail/constants"; import { withError } from "@/utils/middleware"; +import { transferPremiumDuringMerge } from "@/utils/user/merge-premium"; const logger = createScopedLogger("google/linking/callback"); @@ -133,6 +134,13 @@ export const GET = withError(async (request: NextRequest) => { targetUserId, }, ); + + // Transfer premium subscription before deleting the source user + await transferPremiumDuringMerge({ + sourceUserId: existingAccount.userId, + targetUserId, + }); + await prisma.$transaction([ prisma.account.update({ where: { id: existingAccount.id }, diff --git a/apps/web/app/api/outlook/linking/callback/route.ts b/apps/web/app/api/outlook/linking/callback/route.ts index 9352af7144..f914fa8335 100644 --- a/apps/web/app/api/outlook/linking/callback/route.ts +++ b/apps/web/app/api/outlook/linking/callback/route.ts @@ -5,6 +5,7 @@ import { createScopedLogger } from "@/utils/logger"; import { OUTLOOK_LINKING_STATE_COOKIE_NAME } from "@/utils/outlook/constants"; import { withError } from "@/utils/middleware"; import { SafeError } from "@/utils/error"; +import { transferPremiumDuringMerge } from "@/utils/user/merge-premium"; const logger = createScopedLogger("outlook/linking/callback"); @@ -218,6 +219,13 @@ export const GET = withError(async (request: NextRequest) => { email: providerEmail, targetUserId, }); + + // Transfer premium subscription before deleting the source user + await transferPremiumDuringMerge({ + sourceUserId: existingAccount.userId, + targetUserId, + }); + await prisma.$transaction([ prisma.account.update({ where: { id: existingAccount.id }, diff --git a/apps/web/utils/user/merge-premium.test.ts b/apps/web/utils/user/merge-premium.test.ts new file mode 100644 index 0000000000..e2b70b1952 --- /dev/null +++ b/apps/web/utils/user/merge-premium.test.ts @@ -0,0 +1,470 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PremiumTier } from "@prisma/client"; +import { transferPremiumDuringMerge } from "./merge-premium"; +import prisma from "@/utils/__mocks__/prisma"; + +vi.mock("@/utils/prisma"); +vi.mock("server-only", () => ({})); + +describe("transferPremiumDuringMerge", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("when both users have premium subscriptions", () => { + it("should choose source premium when source has higher tier", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sourcePremiumId = "source-premium-id"; + const targetPremiumId = "target-premium-id"; + + // Mock source user with BUSINESS_PLUS tier (higher) + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: sourcePremiumId, + premiumAdminId: null, + premium: { + id: sourcePremiumId, + tier: PremiumTier.BUSINESS_PLUS_MONTHLY, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [], + }, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: targetPremiumId, + premiumAdminId: null, + premium: { + id: targetPremiumId, + tier: PremiumTier.PRO_MONTHLY, + }, + } as any); + + prisma.user.update.mockResolvedValue({} as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not call premium.update since we use atomic user.update + expect(prisma.premium.update).not.toHaveBeenCalled(); + + // Should update target user to use source's premium (atomic operation) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: targetUserId }, + data: { premiumId: sourcePremiumId }, + }); + }); + + it("should keep target premium when target has higher tier", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sourcePremiumId = "source-premium-id"; + const targetPremiumId = "target-premium-id"; + + // Mock source user with PRO tier (lower) + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: sourcePremiumId, + premiumAdminId: null, + premium: { + id: sourcePremiumId, + tier: PremiumTier.PRO_MONTHLY, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [], + }, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: targetPremiumId, + premiumAdminId: null, + premium: { + id: targetPremiumId, + tier: PremiumTier.BUSINESS_PLUS_MONTHLY, + }, + } as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not make any premium updates since target has higher tier + expect(prisma.premium.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("should choose source premium when both have same tier", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sourcePremiumId = "source-premium-id"; + const targetPremiumId = "target-premium-id"; + + // Mock both users with same tier + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: sourcePremiumId, + premiumAdminId: null, + premium: { + id: sourcePremiumId, + tier: PremiumTier.PRO_MONTHLY, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [], + }, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: targetPremiumId, + premiumAdminId: null, + premium: { + id: targetPremiumId, + tier: PremiumTier.PRO_MONTHLY, + }, + } as any); + + prisma.user.update.mockResolvedValue({} as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not call premium.update since we use atomic user.update + expect(prisma.premium.update).not.toHaveBeenCalled(); + + // Should update target user to use source's premium (atomic operation) + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: targetUserId }, + data: { premiumId: sourcePremiumId }, + }); + }); + + it("should do nothing when both users already share the same premium", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sharedPremiumId = "shared-premium-id"; + + // Mock both users with same premium + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: sharedPremiumId, + premiumAdminId: null, + premium: { + id: sharedPremiumId, + tier: PremiumTier.PRO_MONTHLY, + users: [ + { id: sourceUserId, email: "source@example.com" }, + { id: targetUserId, email: "target@example.com" }, + ], + admins: [], + }, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: sharedPremiumId, + premiumAdminId: null, + premium: { + id: sharedPremiumId, + tier: PremiumTier.PRO_MONTHLY, + }, + } as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not make any updates since they share the same premium + expect(prisma.premium.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + }); + + describe("when only source user has premium", () => { + it("should transfer premium to target user", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sourcePremiumId = "source-premium-id"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: sourcePremiumId, + premiumAdminId: null, + premium: { + id: sourcePremiumId, + tier: PremiumTier.PRO_MONTHLY, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [], + }, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + } as any); + + prisma.user.update.mockResolvedValue({} as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should update target user to use source's premium + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: targetUserId }, + data: { premiumId: sourcePremiumId }, + }); + }); + }); + + describe("when only target user has premium", () => { + it("should keep target user's premium", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const targetPremiumId = "target-premium-id"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: targetPremiumId, + premiumAdminId: null, + premium: { + id: targetPremiumId, + tier: PremiumTier.PRO_MONTHLY, + }, + } as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not make any updates since target already has premium + expect(prisma.premium.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + }); + + describe("when neither user has premium", () => { + it("should do nothing", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + } as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not make any updates since neither has premium + expect(prisma.premium.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + }); + + describe("when source user has premium admin rights", () => { + it("should transfer admin rights to target user", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const premiumAdminId = "premium-admin-id"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: null, + premiumAdminId: premiumAdminId, + premium: null, + premiumAdmin: { + id: premiumAdminId, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [{ id: sourceUserId, email: "source@example.com" }], + }, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + } as any); + + prisma.premium.update.mockResolvedValue({} as any); + prisma.user.update.mockResolvedValue({} as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should connect target user as admin + expect(prisma.premium.update).toHaveBeenCalledWith({ + where: { id: premiumAdminId }, + data: { + admins: { + connect: { id: targetUserId }, + }, + }, + }); + + // Should update target user's premiumAdminId + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: targetUserId }, + data: { premiumAdminId: premiumAdminId }, + }); + }); + + it("should not update premiumAdminId when target already has admin rights", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sourcePremiumAdminId = "source-premium-admin-id"; + const targetPremiumAdminId = "target-premium-admin-id"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: null, + premiumAdminId: sourcePremiumAdminId, + premium: null, + premiumAdmin: { + id: sourcePremiumAdminId, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [{ id: sourceUserId, email: "source@example.com" }], + }, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: null, + premiumAdminId: targetPremiumAdminId, + premium: null, + } as any); + + prisma.premium.update.mockResolvedValue({} as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should connect target user as admin + expect(prisma.premium.update).toHaveBeenCalledWith({ + where: { id: sourcePremiumAdminId }, + data: { + admins: { + connect: { id: targetUserId }, + }, + }, + }); + + // Should not update target user's premiumAdminId since they already have one + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + }); + + describe("error handling", () => { + it("should return early when source user is not found", async () => { + const sourceUserId = "non-existent-source"; + const targetUserId = "target-user-id"; + + prisma.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + } as any); + + await transferPremiumDuringMerge({ sourceUserId, targetUserId }); + + // Should not make any updates when source user is not found + expect(prisma.premium.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("should handle error gracefully when target user is not found", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "non-existent-target"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce(null); + + // Should not throw an error, but should complete gracefully + await expect( + transferPremiumDuringMerge({ sourceUserId, targetUserId }), + ).resolves.toBeUndefined(); + + // Should not make any updates when target user is not found + expect(prisma.premium.update).not.toHaveBeenCalled(); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("should handle database errors gracefully", async () => { + const sourceUserId = "source-user-id"; + const targetUserId = "target-user-id"; + const sourcePremiumId = "source-premium-id"; + + prisma.user.findUnique + .mockResolvedValueOnce({ + id: sourceUserId, + email: "source@example.com", + premiumId: sourcePremiumId, + premiumAdminId: null, + premium: { + id: sourcePremiumId, + tier: PremiumTier.PRO_MONTHLY, + users: [{ id: sourceUserId, email: "source@example.com" }], + admins: [], + }, + premiumAdmin: null, + } as any) + .mockResolvedValueOnce({ + id: targetUserId, + email: "target@example.com", + premiumId: null, + premiumAdminId: null, + premium: null, + } as any); + + // Mock database error + prisma.user.update.mockRejectedValue( + new Error("Database connection failed"), + ); + + // Should not throw an error, but should complete gracefully + await expect( + transferPremiumDuringMerge({ sourceUserId, targetUserId }), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/apps/web/utils/user/merge-premium.ts b/apps/web/utils/user/merge-premium.ts new file mode 100644 index 0000000000..7c24d08920 --- /dev/null +++ b/apps/web/utils/user/merge-premium.ts @@ -0,0 +1,218 @@ +import prisma from "@/utils/prisma"; +import { createScopedLogger } from "@/utils/logger"; +import { isOnHigherTier } from "@/utils/premium"; + +const logger = createScopedLogger("user/merge-premium"); + +/** + * Transfer premium subscription from source user to target user during account merge + * This ensures premium subscriptions are preserved when accounts are merged + */ +export async function transferPremiumDuringMerge({ + sourceUserId, + targetUserId, +}: { + sourceUserId: string; + targetUserId: string; +}) { + logger.info("Starting premium transfer during user merge", { + sourceUserId, + targetUserId, + }); + + try { + const sourceUser = await prisma.user.findUnique({ + where: { id: sourceUserId }, + select: { + id: true, + email: true, + premiumId: true, + premiumAdminId: true, + premium: { + select: { + id: true, + tier: true, + users: { select: { id: true, email: true } }, + admins: { select: { id: true, email: true } }, + }, + }, + premiumAdmin: { + select: { + id: true, + users: { select: { id: true, email: true } }, + admins: { select: { id: true, email: true } }, + }, + }, + }, + }); + + if (!sourceUser) { + logger.warn("Source user not found", { sourceUserId }); + return; + } + + const targetUser = await prisma.user.findUnique({ + where: { id: targetUserId }, + select: { + id: true, + email: true, + premiumId: true, + premiumAdminId: true, + premium: { + select: { + id: true, + tier: true, + }, + }, + }, + }); + + if (!targetUser) { + logger.error("Target user not found", { targetUserId }); + throw new Error(`Target user ${targetUserId} not found`); + } + + const operations: Promise[] = []; + + // Handle premium subscription scenarios + if (sourceUser.premiumId && targetUser.premiumId) { + // Both users have premium - choose the higher tier + const sourceTier = sourceUser.premium?.tier; + const targetTier = targetUser.premium?.tier; + + logger.warn( + "Both users have premium subscriptions - choosing higher tier", + { + sourcePremiumId: sourceUser.premiumId, + targetPremiumId: targetUser.premiumId, + sourceTier, + targetTier, + sourceUserId, + targetUserId, + }, + ); + + // If same premium or source has higher tier, use source premium + // If target has higher tier, keep target premium + const shouldUseSourcePremium = + sourceUser.premiumId === targetUser.premiumId || + !isOnHigherTier(targetTier, sourceTier); + + if ( + shouldUseSourcePremium && + sourceUser.premiumId !== targetUser.premiumId + ) { + // Update target user to use source's premium (atomic operation that handles the relationship change) + operations.push( + prisma.user.update({ + where: { id: targetUserId }, + data: { premiumId: sourceUser.premiumId }, + }), + ); + + logger.info( + "Target user's premium subscription replaced with source user's higher tier premium", + { chosenTier: sourceTier }, + ); + } else { + logger.info( + "Target user keeps their premium subscription (higher or equal tier)", + { chosenTier: targetTier }, + ); + } + } else if (sourceUser.premiumId && sourceUser.premium) { + // Only source user has premium - transfer to target + logger.info("Transferring premium subscription from source to target", { + premiumId: sourceUser.premiumId, + sourceUserId, + targetUserId, + }); + + // Update target user to use source's premium + operations.push( + prisma.user.update({ + where: { id: targetUserId }, + data: { premiumId: sourceUser.premiumId }, + }), + ); + } else if (targetUser.premiumId) { + // Only target user has premium - no action needed, they keep their premium + logger.info("Target user already has premium, no transfer needed", { + targetPremiumId: targetUser.premiumId, + targetUserId, + }); + } else { + // Neither user has premium + logger.info("Neither user has premium subscription", { + sourceUserId, + targetUserId, + }); + } + + // Handle premium admin transfer (user is a premium admin) + if (sourceUser.premiumAdminId && sourceUser.premiumAdmin) { + logger.info("Transferring premium admin rights", { + premiumId: sourceUser.premiumAdminId, + sourceUserId, + targetUserId, + }); + + if (targetUser.premiumAdminId) { + logger.warn( + "Target user already has premium admin rights, will merge", + { + sourcePremiumAdminId: sourceUser.premiumAdminId, + targetPremiumAdminId: targetUser.premiumAdminId, + }, + ); + } + + // Connect target user as admin to source premium admin + operations.push( + prisma.premium.update({ + where: { id: sourceUser.premiumAdminId }, + data: { + admins: { + connect: { id: targetUserId }, + }, + }, + }), + ); + + // Update target user's premiumAdminId if they don't have one + if (!targetUser.premiumAdminId) { + operations.push( + prisma.user.update({ + where: { id: targetUserId }, + data: { premiumAdminId: sourceUser.premiumAdminId }, + }), + ); + } + } + + // Execute all premium transfer operations + if (operations.length > 0) { + logger.info("Executing premium transfer operations", { + operationCount: operations.length, + sourceUserId, + targetUserId, + }); + + await Promise.all(operations); + + logger.info("Premium transfer completed successfully", { + sourceUserId, + targetUserId, + }); + } else { + logger.info("No premium to transfer", { sourceUserId, targetUserId }); + } + } catch (error) { + logger.error("Failed to transfer premium during user merge", { + sourceUserId, + targetUserId, + error: error instanceof Error ? error.message : String(error), + }); + // Don't rethrow - we want the merge to continue even if premium transfer fails + } +} diff --git a/version.txt b/version.txt index 1fa6ccd53d..6699cef779 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -v2.9.6 +v2.9.7