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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/app/api/google/linking/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -133,6 +134,13 @@ export const GET = withError(async (request: NextRequest) => {
targetUserId,
},
);

// Transfer premium subscription before deleting the source user
await transferPremiumDuringMerge({
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Premium transfer runs outside the surrounding DB transaction, risking inconsistent state if later merge operations fail.

Prompt for AI agents
Address the following comment on apps/web/app/api/google/linking/callback/route.ts at line 139:

<comment>Premium transfer runs outside the surrounding DB transaction, risking inconsistent state if later merge operations fail.</comment>

<file context>
@@ -133,6 +134,13 @@ export const GET = withError(async (request: NextRequest) =&gt; {
     );
+
+    // Transfer premium subscription before deleting the source user
+    await transferPremiumDuringMerge({
+      sourceUserId: existingAccount.userId,
+      targetUserId,
</file context>
Fix with Cubic

sourceUserId: existingAccount.userId,
targetUserId,
});

Comment on lines +138 to +143
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Run premium transfer inside the same transaction as reassignment/deletion

Calling the transfer before a separate $transaction risks partial merges if later steps fail. Wrap all steps in a single interactive transaction and pass a tx into the utility.

Example:

await prisma.$transaction(async (tx) => {
  await transferPremiumDuringMerge(
    { sourceUserId: existingAccount.userId, targetUserId },
    { tx },
  );

  await tx.account.update({
    where: { id: existingAccount.id },
    data: { userId: targetUserId },
  });

  await tx.emailAccount.update({
    where: { accountId: existingAccount.id },
    data: {
      userId: targetUserId,
      name: existingAccount.user.name,
      email: existingAccount.user.email,
    },
  });

  await tx.user.delete({ where: { id: existingAccount.userId } });
});

Optional: Apply the same change to the Outlook linking route for parity.


🏁 Script executed:

#!/bin/bash
# Verify the Outlook route also wraps merge + premium transfer in one transaction
rg -nC2 -g 'apps/web/app/api/**/linking/**/route.ts' -P 'transferPremiumDuringMerge\(|\$transaction\('

Length of output: 1666


Wrap premium transfer and merge steps in a single transaction
Move the call to transferPremiumDuringMerge into the same prisma.$transaction block as the account/user updates and deletion, passing in the transaction client. Apply this change in both:

  • apps/web/app/api/google/linking/callback/route.ts (lines 138–143 & 144–146)
  • apps/web/app/api/outlook/linking/callback/route.ts (lines 223–227 & 229–231)
- await transferPremiumDuringMerge({ sourceUserId, targetUserId });
- await prisma.$transaction([ …updates…, …deletion… ]);
+ await prisma.$transaction(async (tx) => {
+   await transferPremiumDuringMerge(
+     { sourceUserId, targetUserId },
+     { tx },
+   );
+   await tx.account.update({ … });
+   await tx.emailAccount.update({ … });
+   await tx.user.delete({ … });
+ });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Transfer premium subscription before deleting the source user
await transferPremiumDuringMerge({
sourceUserId: existingAccount.userId,
targetUserId,
});
// Transfer premium subscription before deleting the source user
await prisma.$transaction(async (tx) => {
// first, move premium over using the same transaction client
await transferPremiumDuringMerge(
{ sourceUserId: existingAccount.userId, targetUserId },
{ tx },
);
// then perform the merge updates and deletion atomically
await tx.account.update({
/* …existing account.update parameters… */
});
await tx.emailAccount.update({
/* …existing emailAccount.update parameters… */
});
await tx.user.delete({
/* …existing user.delete parameters… */
});
});
🤖 Prompt for AI Agents
In apps/web/app/api/google/linking/callback/route.ts around lines 138-143 (and
144-146) and apps/web/app/api/outlook/linking/callback/route.ts around lines
223-227 (and 229-231), the call to transferPremiumDuringMerge is executed
outside the prisma.$transaction; move that call into the same
prisma.$transaction block that performs the account/user updates and deletion,
remove the standalone call, and pass the transaction client (e.g., tx) into
transferPremiumDuringMerge so it uses the transactional Prisma client for any DB
operations.

await prisma.$transaction([
prisma.account.update({
where: { id: existingAccount.id },
Expand Down
8 changes: 8 additions & 0 deletions apps/web/app/api/outlook/linking/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down Expand Up @@ -218,6 +219,13 @@ export const GET = withError(async (request: NextRequest) => {
email: providerEmail,
targetUserId,
});

// Transfer premium subscription before deleting the source user
await transferPremiumDuringMerge({
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Premium updates are executed outside the merge transaction, risking partial state if $transaction later fails; consider performing all writes in a single interactive transaction

Prompt for AI agents
Address the following comment on apps/web/app/api/outlook/linking/callback/route.ts at line 224:

<comment>Premium updates are executed outside the merge transaction, risking partial state if $transaction later fails; consider performing all writes in a single interactive transaction</comment>

<file context>
@@ -218,6 +219,13 @@ export const GET = withError(async (request: NextRequest) =&gt; {
     });
+
+    // Transfer premium subscription before deleting the source user
+    await transferPremiumDuringMerge({
+      sourceUserId: existingAccount.userId,
+      targetUserId,
</file context>
Fix with Cubic

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Sep 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transferPremiumDuringMerge assumes a user can belong to multiple premiums; schema enforces a single premiumId per user, so connecting here will overwrite the target's current premium rather than "maintain access to both"

Prompt for AI agents
Address the following comment on apps/web/app/api/outlook/linking/callback/route.ts at line 224:

<comment>transferPremiumDuringMerge assumes a user can belong to multiple premiums; schema enforces a single premiumId per user, so connecting here will overwrite the target&#39;s current premium rather than &quot;maintain access to both&quot;</comment>

<file context>
@@ -218,6 +219,13 @@ export const GET = withError(async (request: NextRequest) =&gt; {
     });
+
+    // Transfer premium subscription before deleting the source user
+    await transferPremiumDuringMerge({
+      sourceUserId: existingAccount.userId,
+      targetUserId,
</file context>
Fix with Cubic

sourceUserId: existingAccount.userId,
targetUserId,
});

Comment on lines +223 to +228
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Make premium transfer atomic with the merge transaction

Running transferPremiumDuringMerge before the $transaction risks partial state (premium moved but merge fails or vice‑versa). Execute the premium transfer inside the same Prisma transaction using an interactive transaction and pass a TransactionClient to the helper.

Apply this refactor:

-    // Transfer premium subscription before deleting the source user
-    await transferPremiumDuringMerge({
-      sourceUserId: existingAccount.userId,
-      targetUserId,
-    });
-
-    await prisma.$transaction([
-      prisma.account.update({
-        where: { id: existingAccount.id },
-        data: { userId: targetUserId },
-      }),
-      prisma.emailAccount.update({
-        where: { accountId: existingAccount.id },
-        data: {
-          userId: targetUserId,
-          name: existingAccount.user.name,
-          email: existingAccount.user.email,
-        },
-      }),
-      prisma.user.delete({
-        where: { id: existingAccount.userId },
-      }),
-    ]);
+    await prisma.$transaction(async (tx) => {
+      // Transfer premium subscription before deleting the source user
+      await transferPremiumDuringMerge(
+        { sourceUserId: existingAccount.userId, targetUserId },
+        tx,
+      );
+
+      await tx.account.update({
+        where: { id: existingAccount.id },
+        data: { userId: targetUserId },
+      });
+
+      await tx.emailAccount.update({
+        where: { accountId: existingAccount.id },
+        data: {
+          userId: targetUserId,
+          name: existingAccount.user.name,
+          email: existingAccount.user.email,
+        },
+      });
+
+      await tx.user.delete({
+        where: { id: existingAccount.userId },
+      });
+    });

Update the helper to accept a transaction client (outside this file):

// apps/web/utils/user/merge-premium.ts
import type { Prisma } from "@prisma/client";
import prisma from "@/utils/prisma";

export async function transferPremiumDuringMerge(
  params: { sourceUserId: string; targetUserId: string },
  db: Prisma.TransactionClient = prisma,
) {
  // use `db` instead of `prisma` for all queries
}

Optional: consider isolationLevel: "Serializable" (Postgres) if concurrent merges are possible.
To verify other call sites also run inside a transaction:


🏁 Script executed:

#!/bin/bash
rg -n -C3 'transferPremiumDuringMerge\('

Length of output: 1677


Make premium transfer atomic in merge transactions

  • In apps/web/app/api/outlook/linking/callback/route.ts (≈line 223) and apps/web/app/api/google/linking/callback/route.ts (≈line 139), wrap transferPremiumDuringMerge plus the account updates and user deletion in a single
    await prisma.$transaction(async (tx) => {
      await transferPremiumDuringMerge({ sourceUserId, targetUserId }, tx);
      // tx.account.update…
      // tx.emailAccount.update…
      // tx.user.delete…
    });
    passing tx into the helper.
  • Refactor transferPremiumDuringMerge (apps/web/utils/user/merge-premium.ts) to accept a Prisma.TransactionClient (default prisma) and use it for all queries.

(Optional) consider isolationLevel: "Serializable" for stricter concurrency control.

🤖 Prompt for AI Agents
In apps/web/app/api/outlook/linking/callback/route.ts around lines 223-228 (and
similarly apps/web/app/api/google/linking/callback/route.ts around line 139),
the call to transferPremiumDuringMerge and the subsequent account updates and
user deletion must be executed inside a single prisma transaction; wrap those
operations in await prisma.$transaction(async (tx) => { ... }) and pass tx into
transferPremiumDuringMerge so it uses the transaction client for its queries;
update apps/web/utils/user/merge-premium.ts to accept an optional
Prisma.TransactionClient parameter (defaulting to prisma) and replace all
prisma.* calls inside the helper with the passed tx to ensure the premium
transfer is atomic with the account updates and user deletion (optionally add
isolationLevel: "Serializable" to the transaction call for stricter
concurrency).

await prisma.$transaction([
prisma.account.update({
where: { id: existingAccount.id },
Expand Down
Loading
Loading