Skip to content

Fix partner account merge failing on overlapping program enrollments#4047

Open
pepeladeira wants to merge 10 commits into
mainfrom
partner-account-merge-overlapping-enrollments
Open

Fix partner account merge failing on overlapping program enrollments#4047
pepeladeira wants to merge 10 commits into
mainfrom
partner-account-merge-overlapping-enrollments

Conversation

@pepeladeira

@pepeladeira pepeladeira commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features
    • Added a workflow-backed partner account merge operation for more reliable, background processing and improved failure handling.
  • Refactor
    • Updated the merge trigger to use the workflow system instead of the previous cron-based approach.
    • Extended workflow configuration to support the new partner-merge workflow type.
  • Bug Fixes
    • Improved merge consistency for overlapping vs non-overlapping program enrollments and their related records.
    • Enhanced cleanup and reporting during merge (including cached verification state and partner notifications).

@vercel

vercel Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Jun 17, 2026 10:14pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The partner account merge logic is migrated from a synchronous cron handler to an Upstash Workflow-backed API route with durable steps. The new route validates input, loads a merge plan to compute overlapping vs. transfer program enrollments, processes each enrollment with idempotent durable steps, transfers bounties and syncs links/commissions, deletes source user and cleanup fraud events, removes the source partner record, and sends notifications. The workflow is triggered via triggerQStashWorkflow with new type registration for correlation logging.

Changes

Partner Account Merge Workflow Migration

Layer / File(s) Summary
Workflow handler and durable step orchestration
apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
New Upstash Workflow-backed POST handler validates input, orchestrates durable steps for loading a merge plan, iterating per-enrollment processing, transferring bounties, syncing links and commissions, deleting source user, cleaning up fraud events, deleting the source partner, and sending notifications; includes failure handling with Redis cache clearing and structured alert logging.
Merge plan loading and computation
apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
loadMergePlan locates source and target partners by email, computes overlapping program IDs (same on both) vs. transfer IDs (source-only), and returns a structured plan with source image, user details, rewind status, and program categorization.
Enrollment transfer and merging helpers
apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
transferRowsInBatches provides looped batch updates; transferPartnerProgramData reassigns partnerId across multiple tables using batched updateMany for high-volume and single updateMany for low-volume entities; mergeSingleEnrollment handles idempotent retries, skipping migrated enrollments, transferring program data, and either merging overlaps in a transaction (with status/applicationId reconciliation, source enrollment deletion, and tenantId propagation) or transferring non-overlaps.
Post-merge bounties, links, user, fraud, and partner cleanup
apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
Sequential cleanup: transferBountySubmissions moves source-exclusive bounties; syncLinksAndCommissions records links and triggers commission sync for transferred programs; deleteSourceUser conditionally removes user and R2 image if no projects remain; cleanupFraudEvents deletes duplicate-account fraud events and resolves fraud groups; deleteSourcePartner deletes partner via SQL and removes partner image from R2.
Workflow trigger and type registration
apps/web/lib/actions/partners/merge-partner-accounts.ts, apps/web/lib/cron/qstash-workflow.ts
Replaces direct cron publish with triggerQStashWorkflow call specifying workflowType: "merge-partner-account" with userId/sourceEmail/targetEmail in body and parallelism: 1; registers new workflow type in WorkflowType union and adds getWorkflowConfig case returning correlation metadata.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • dubinc/dub#3756: The new workflow's cleanupFraudEvents step uses resolveFraudGroups with the same fraud-group resolution criteria updated in that PR.
  • dubinc/dub#3782: The PR implements the fraud rule type swap for partnerDuplicateAccount cleanup that is now used by the new merge workflow's fraud cleanup step.

Suggested reviewers

  • steven-tey

🐇 A cron becomes a workflow, durable steps take flight,
Enrollments merge and fraud events purge—partnerships unite!
Idempotent and resilient, accounts are consolidated with care,
One bunny hops with data integrity through the merge workflow air! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: fixing a partner account merge bug related to overlapping program enrollments. It is concise, specific, and clearly communicates the primary objective of the PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch partner-account-merge-overlapping-enrollments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/email/src/templates/partner-account-merge-failed.tsx (1)

17-21: 💤 Low value

Consider a more generic default email value.

The default email "panic@thedis.co" appears to be a test/placeholder value. While this default is unlikely to be used in production (since the route always passes the actual targetEmail), consider using a more generic placeholder like "user@example.com" or adding a comment to clarify this is for development/testing only.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/email/src/templates/partner-account-merge-failed.tsx` around lines
17 - 21, The default email value in the PartnerAccountMergeFailed function
parameter is set to a test/placeholder value "panic@thedis.co" which appears
unprofessional. Either replace this default email with a more generic
placeholder value like "user@example.com", or add a comment above the function
explaining that this default is for development/testing purposes only and is not
used in production since the actual targetEmail is always passed by the route.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/`(ee)/api/cron/partners/merge-accounts/route.ts:
- Around line 549-573: The PartnerAccountMergeFailed email template always
indicates that no data was merged, but when mergedProgramIds.length > 0, some
enrollments have already been transferred before the failure. Pass
mergedProgramIds to the PartnerAccountMergeFailed template component (in the
react prop of the sendEmail call) so it can conditionally display different
messaging indicating that a partial merge occurred and some data was
transferred, or alternatively use a separate email template when
mergedProgramIds.length > 0 to accurately reflect the partial merge scenario to
the user.

In `@packages/email/src/templates/partner-account-merge-failed.tsx`:
- Around line 42-45: The email message in the Text component (lines 42-45)
incorrectly claims "no data was merged or deleted" unconditionally, but the code
shows that partial merges are possible and explicitly tracked via
mergedProgramIds. Update the email template to conditionally display different
messages based on whether a partial merge occurred: if mergedProgramIds contains
transferred enrollments, the message should inform the user which programs were
merged and suggest they contact support; if no transfers occurred, use the
current message. Alternatively, confirm through code review that errors can only
occur before any transfers begin, in which case remove the partial-merge
tracking logic (mergedProgramIds population and the conditional check at line
550) to eliminate the contradiction.

---

Nitpick comments:
In `@packages/email/src/templates/partner-account-merge-failed.tsx`:
- Around line 17-21: The default email value in the PartnerAccountMergeFailed
function parameter is set to a test/placeholder value "panic@thedis.co" which
appears unprofessional. Either replace this default email with a more generic
placeholder value like "user@example.com", or add a comment above the function
explaining that this default is for development/testing purposes only and is not
used in production since the actual targetEmail is always passed by the route.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5a4872ec-8b08-4424-bf40-c993ed2e1b8f

📥 Commits

Reviewing files that changed from the base of the PR and between fcc6e9a and 2ab3016.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts
  • packages/email/src/templates/partner-account-merge-failed.tsx

Comment thread apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts Outdated
Comment thread packages/email/src/templates/partner-account-merge-failed.tsx Outdated
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review. any breaking changes?

@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== merge-accounts route ===" 
cat apps/web/app/\(ee\)/api/cron/partners/merge-accounts/route.ts

echo ""
echo "=== partner-account-merge-failed email template ==="
cat packages/email/src/templates/partner-account-merge-failed.tsx

echo ""
echo "=== Check email exports/index ==="
fd "index" packages/email/src --max-depth 2 -e ts -e tsx | head -5 | xargs cat 2>/dev/null || true

echo ""
echo "=== Check if the email template is exported ==="
rg "PartnerAccountMergeFailed\|merge-failed\|merge_failed" packages/email/src --include="*.ts" --include="*.tsx" -n

Length of output: 21336


🏁 Script executed:

#!/bin/bash
echo "=== Look for existing email sends in the route for pattern reference ==="
rg "sendEmail\|from.*email\|PartnerAccount" apps/web/app/\(ee\)/api/cron/partners/merge-accounts/ -n -C2 2>/dev/null || true

echo ""
echo "=== Check how other email templates are exported ==="
cat packages/email/src/index.ts 2>/dev/null || fd "index" packages/email/src -e ts | head -3 | xargs cat 2>/dev/null || true

echo ""
echo "=== Check for ProgramEnrollment model fields (especially applicationId and tenantId) ==="
rg "applicationId\|tenantId" apps/web/app/\(ee\)/api/cron/partners/merge-accounts/route.ts -n || true

echo ""
echo "=== Check prisma schema for ProgramEnrollment ==="
rg "model ProgramEnrollment" -A 40 --include="*.prisma" -r . || true

echo ""
echo "=== Check for any existing tests for this cron route ==="
fd "merge-accounts\|merge_accounts" --include="*.test.*" --include="*.spec.*" . 2>/dev/null || fd "merge" . --max-depth 8 -e test.ts -e spec.ts 2>/dev/null | head -10 || true

Length of output: 3487


🏁 Script executed:

#!/bin/bash
echo "=== Full route file ==="
cat "apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts"

Length of output: 16817


🏁 Script executed:

#!/bin/bash
# Use find instead
find . -path "*/cron/partners/merge-accounts/route.ts" -exec cat {} \;

Length of output: 16786


🏁 Script executed:

#!/bin/bash
# Try different quoting
file_path=$(find . -name "route.ts" -path "*/merge-accounts/*" 2>/dev/null | head -1)
echo "Found: $file_path"
cat "$file_path"

Length of output: 16917


🏁 Script executed:

#!/bin/bash
find . -name "route.ts" -path "*merge*" 2>/dev/null
find . -name "*.tsx" -path "*merge-failed*" 2>/dev/null

Length of output: 290


🏁 Script executed:

#!/bin/bash
ls apps/web/app/ 2>/dev/null | head -20
ls apps/web/app/api/ 2>/dev/null | head -20
find apps -type d -name "merge-accounts" 2>/dev/null
find packages/email -name "*merge*" 2>/dev/null

Length of output: 819


🏁 Script executed:

#!/bin/bash
# Check git diff for the PR
git diff origin/main --name-only 2>/dev/null || git log --oneline -5
git show --stat HEAD 2>/dev/null | head -20

Length of output: 899


🏁 Script executed:

#!/bin/bash
git log --oneline -3
git diff HEAD~1 --name-only 2>/dev/null

Length of output: 332


🏁 Script executed:

#!/bin/bash
git log --oneline -5
git diff HEAD~1 HEAD -- . 2>/dev/null | head -500

Length of output: 1574


🏁 Script executed:

#!/bin/bash
git show HEAD --stat

Length of output: 356


🏁 Script executed:

#!/bin/bash
# Try to get the PR diff via GitHub CLI
gh pr diff 4047 --repo dubinc/dub 2>/dev/null | head -800

Length of output: 20178


🏁 Script executed:

#!/bin/bash
gh pr view 4047 --repo dubinc/dub --json files 2>/dev/null | jq '.files[].path' 2>/dev/null || true
gh pr diff 4047 --repo dubinc/dub 2>/dev/null | head -1000

Length of output: 20365


🏁 Script executed:

#!/bin/bash
gh pr diff 4047 --repo dubinc/dub 2>/dev/null

Length of output: 20126



@pepeladeira Here is the full review:


PR Review: Fix partner account merge failing on overlapping program enrollments

Summary of Changes

File Change
apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts Major refactor of merge logic
packages/email/src/templates/partner-account-merge-failed.tsx New failure notification email template

Breaking Changes

No breaking changes. The public contract is identical:

  • POST /api/cron/partners/merge-accounts still accepts { userId, sourceEmail, targetEmail } (same Zod schema).
  • The route is protected by QStash signature verification — no external consumers are affected.
  • No Prisma schema changes.
  • No new environment variables required.

Root Cause Fixed ✅

The old code deleted the source ProgramEnrollment directly when both partners shared a program, but the enrollment could have a FK-constrained applicationId. This caused a foreign key constraint violation at the DB level. The fix correctly nulls out applicationId before deletion:

if (sourceEnrollment.applicationId) {
  await tx.programEnrollment.update({
    where: { id: sourceEnrollment.id },
    data: { applicationId: null },
  });
}
await tx.programEnrollment.delete({ where: { id: sourceEnrollment.id } });

Issues Found

1. 🔴 Stale sourceEnrollment in overlapping transaction path

Inside the prisma.$transaction callback, sourceEnrollment comes from the pre-transaction snapshot (fetched by the outer prisma.programEnrollment.findMany). If another process modifies the source enrollment between the snapshot and the transaction, you're operating on stale data. This specifically matters for sourceEnrollment.applicationId and sourceEnrollment.status.

Recommendation: Re-fetch sourceEnrollment inside the transaction:

await prisma.$transaction(async (tx) => {
  const [liveSourceEnrollment, targetEnrollment] = await Promise.all([
    tx.programEnrollment.findUnique({ where: { id: sourceEnrollment.id } }),
    tx.programEnrollment.findUnique({
      where: {
        partnerId_programId: {
          partnerId: mergeTargetPartnerId,
          programId: sourceEnrollment.programId,
        },
      },
    }),
  ]);

  if (!liveSourceEnrollment) {
    // Already processed (partial retry scenario) — skip
    mergedAsOverlap = false;
    return;
  }
  // ... use liveSourceEnrollment instead of sourceEnrollment
});

This also addresses the partial-merge retry issue in point 2.


2. 🟡 No idempotency guard for partial-merge retries

If the cron fails mid-loop (e.g., after 3 of 5 enrollments are migrated), a retry would try to process the already-deleted source enrollments. Since those records no longer exist, tx.programEnrollment.findUnique for the source would return null in the overlap path — but the code in transferProgramEnrollment calls tx.programEnrollment.update({ where: { id: sourceEnrollment.id } }) which throws a Prisma "record not found" error, propagating up as another failure.

The partialMergeNote in the error log is a good operational safety net, but a guard inside the loop would make retries safe:

// At the start of the transaction, check if source enrollment still exists
const stillExists = await tx.programEnrollment.findUnique({ where: { id: sourceEnrollment.id } });
if (!stillExists) {
  console.log(`Enrollment ${sourceEnrollment.id} already processed, skipping`);
  return;
}

3. 🟡 transferPartnerProgramData runs inside the enrollment transaction but could conflict

For overlapping enrollments, transferPartnerProgramData is called before the source enrollment is deleted. If the link table (or others) has a FK pointing to ProgramEnrollment, updating link.partnerId to mergeTargetPartnerId while the source enrollment still exists might violate a unique constraint if a link for (mergeTargetPartnerId, programId) already exists (since links transferred from source overlap with existing target links).

This is unlikely if links have unique identifiers independent of (partnerId, programId), but worth confirming if link has any unique index on (programId, partnerId).


4. 🟢 (Minor) targetPartnerId outer-scope variable is set but never read in the happy path

targetPartnerId = mergeTargetPartnerId is only used in the catch block's log message. This is correct and intentional — just noting it's clear.


5. 🟢 (Minor) transferEnrollments variable declared but loop spreads both arrays inline

const transferEnrollments = sourceEnrollments.filter(...);

for (const sourceEnrollment of [...overlappingEnrollments, ...transferEnrollments]) {

transferEnrollments could just be inlined as a filter in the spread, but the current approach is readable. No issue.


New Behavior Notes

Behavior Before After
Overlapping program enrollments ❌ Crashed (FK violation) ✅ Handled correctly
Data transfer for overlapping programs ❌ Skipped ✅ Done
applicationId cleanup before delete ❌ Missing ✅ Added
tenantId uniqueness check ❌ Missing ✅ Added
Alert on failure Silent mention: true (pings team)
Failure email None Sent to targetEmail
Partial merge forensics Basic error message Detailed log with program IDs

Email Template (partner-account-merge-failed.tsx) ✅

Clean and consistent with the existing partner-account-merged template. Copy is clear and actionable. The panic@thedis.co default is an internal-only fallback — no concern.


Overall Assessment

The core bug fix is correct and the architecture (sequential per-enrollment transactions with an in-transaction re-read of targetEnrollment) is sound. The main concerns are around partial-merge retry safety (#2) and stale source enrollment data (#1), which are related and could be addressed together by re-fetching sourceEnrollment inside the transaction. These are edge cases and don't affect the primary bug fix.

@coderabbitai coderabbitai Bot left a comment

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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts (1)

278-304: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Re-fetch sourceEnrollment inside the transaction to avoid stale-data bugs.

The sourceEnrollment object is fetched outside the transaction (line 250) but used inside (lines 291, 299). If another process modifies or deletes this enrollment between the snapshot and execution, the code operates on stale data—leading to incorrect status comparisons, wrong applicationId checks, or tenantId mismatches.

Additionally, on retry after a mid-loop failure, already-deleted source enrollments would cause "record not found" errors since there's no existence check.

🔧 Suggested fix: re-fetch and guard inside the transaction
       await prisma.$transaction(async (tx) => {
+        // Re-fetch source enrollment to ensure it still exists and get fresh data
+        const freshSourceEnrollment = await tx.programEnrollment.findUnique({
+          where: { id: sourceEnrollment.id },
+        });
+
+        if (!freshSourceEnrollment) {
+          // Already processed in a previous run or deleted externally
+          console.log(`Source enrollment ${sourceEnrollment.id} no longer exists, skipping`);
+          return;
+        }
+
         const targetEnrollment = await tx.programEnrollment.findUnique({
           where: {
             partnerId_programId: {
               partnerId: mergeTargetPartnerId,
-              programId: sourceEnrollment.programId,
+              programId: freshSourceEnrollment.programId,
             },
           },
         });

         if (targetEnrollment) {
           mergedAsOverlap = true;
           await mergeOverlappingProgramEnrollment(tx, {
-            sourceEnrollment,
+            sourceEnrollment: freshSourceEnrollment,
             targetEnrollment,
             mergeSourcePartnerId,
             mergeTargetPartnerId,
           });
           return;
         }

         await transferProgramEnrollment(tx, {
-          sourceEnrollment,
+          sourceEnrollment: freshSourceEnrollment,
           mergeSourcePartnerId,
           mergeTargetPartnerId,
         });
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/app/`(ee)/api/cron/partners/merge-accounts/route.ts around lines 278
- 304, The sourceEnrollment object is fetched outside the transaction but used
inside the prisma.$transaction block, creating a race condition where concurrent
processes could modify or delete it between the initial fetch and execution.
Inside the transaction callback, before using sourceEnrollment (in the
mergeOverlappingProgramEnrollment and transferProgramEnrollment calls), re-fetch
it using tx.programEnrollment.findUnique with the same query parameters used to
find targetEnrollment. Add a guard check to ensure the re-fetched
sourceEnrollment exists; if it does not exist, return early or throw an
appropriate error to handle the case where the enrollment was deleted between
the initial query and transaction execution.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/web/app/`(ee)/api/cron/partners/merge-accounts/route.ts:
- Around line 278-304: The sourceEnrollment object is fetched outside the
transaction but used inside the prisma.$transaction block, creating a race
condition where concurrent processes could modify or delete it between the
initial fetch and execution. Inside the transaction callback, before using
sourceEnrollment (in the mergeOverlappingProgramEnrollment and
transferProgramEnrollment calls), re-fetch it using
tx.programEnrollment.findUnique with the same query parameters used to find
targetEnrollment. Add a guard check to ensure the re-fetched sourceEnrollment
exists; if it does not exist, return early or throw an appropriate error to
handle the case where the enrollment was deleted between the initial query and
transaction execution.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3249224c-2e25-42b7-8e65-5e862caf5d10

📥 Commits

Reviewing files that changed from the base of the PR and between f5f0f4e and d2b2d53.

📒 Files selected for processing (1)
  • apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/lib/cron/qstash-workflow.ts (1)

52-52: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Redact merge emails before logging workflow failures.

This new workflow carries sourceEmail and targetEmail; logging workflows or returning those fields in correlation writes raw email addresses into operational logs/alerts. Keep correlation useful without storing the emails directly.

Proposed fix
-      console.error("QStash workflow trigger failed", { error, workflows });
+      console.error("QStash workflow trigger failed", {
+        error,
+        workflows: workflows.map((workflow) => ({
+          workflowType: workflow.workflowType,
+          workflowLabel: workflow.workflowLabel,
+          correlation: getWorkflowConfig(workflow).correlation,
+        })),
+      });
         correlation: {
           userId: body.userId,
-          sourceEmail: body.sourceEmail,
-          targetEmail: body.targetEmail,
+          sourceEmailPresent: typeof body.sourceEmail === "string",
+          targetEmailPresent: typeof body.targetEmail === "string",
         },

Also applies to: 110-114

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/lib/cron/qstash-workflow.ts` at line 52, Remove sensitive email
address fields from the workflows object before logging it in the console.error
call at line 52 and before including it in any correlation data returned around
lines 110-114. Create a redacted version of the workflows data that excludes
sourceEmail and targetEmail fields, and use that redacted version for logging
and correlation purposes instead of passing the raw workflows object directly to
console.error or returning unredacted email fields in correlation data.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/app/`(ee)/api/workflows/merge-partner-account/route.ts:
- Around line 437-443: The delete and update operations on enrollments in this
route only filter by enrollment id, which creates a race condition where another
process could reassign the enrollment to a different partner before the mutation
executes, causing the operation to affect the wrong partner's data. Add
sourcePartnerId as an additional WHERE clause condition to all database delete
and update operations that modify enrollments (at the lines mentioned and any
similar operations) to ensure mutations only affect enrollments that still
belong to the source partner, preventing unauthorized modifications to other
partners' enrollments.
- Around line 598-611: The Promise.allSettled call completes successfully even
when individual promises reject, preventing failed syncs from being retried.
After the Promise.allSettled resolves, check the results array for any rejected
promises from recordLink, linkCache.expireMany, or syncTotalCommissions calls,
and throw an error if any rejections are found so the durable step fails and can
be retried. This ensures post-merge sync failures in Tinybird, cache operations,
or commission calculations are not silently ignored.
- Around line 58-61: In the early return path where plan.proceed is false, the
merge cache is not being cleared, causing the cache entry for the user to remain
set and prevent clean retries. Before the return statement in the if
(!plan.proceed) block, add a cache clear operation using the key format
${CACHE_KEY_PREFIX}:${userId} to ensure the cache is properly cleaned up when
skipping the merge due to "not found" or "same account" conditions.
- Around line 733-749: The current try-catch block swallows the error from the
DELETE FROM Partner database operation, which causes the workflow to continue
and send success emails even when the deletion fails. Instead of catching and
returning a log for all errors in this block, restructure the code to let
database deletion errors propagate and fail the workflow while handling image
cleanup separately as a non-critical operation. Move the storage.delete call
into its own try-catch block that only logs errors without affecting the
workflow outcome, and either remove the try-catch from the Partner deletion or
rethrow the error after logging if you need to perform cleanup before the
deletion fails.
- Around line 187-221: The failureFunction in the merge-partner-account workflow
failure handler currently only clears cache and logs internally but does not
send a failure notification to the target email. Add code in the failureFunction
to send the failed-merge notification template to the target email address with
an idempotency key before the function returns. Extract the target email from
the context or inputSchema as needed, and ensure the notification is sent with
proper error context (such as correlation, workflowRunId, and failStatus) to
inform the partner that their merge operation failed.

In `@apps/web/lib/actions/partners/merge-partner-accounts.ts`:
- Around line 343-355: The triggerQStashWorkflow call does not check its return
value, which can be null when retries are exhausted, causing the action to
proceed as if the workflow was successfully enqueued when it actually failed.
Capture the return value from the triggerQStashWorkflow function call and check
if it returns null. If it does return null, throw an error to properly propagate
the failure to the caller so they know the merge workflow failed to enqueue
instead of silently continuing.

---

Outside diff comments:
In `@apps/web/lib/cron/qstash-workflow.ts`:
- Line 52: Remove sensitive email address fields from the workflows object
before logging it in the console.error call at line 52 and before including it
in any correlation data returned around lines 110-114. Create a redacted version
of the workflows data that excludes sourceEmail and targetEmail fields, and use
that redacted version for logging and correlation purposes instead of passing
the raw workflows object directly to console.error or returning unredacted email
fields in correlation data.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0dd8747c-0056-4832-bb25-07ac8cb64db1

📥 Commits

Reviewing files that changed from the base of the PR and between d2b2d53 and a2f83d9.

📒 Files selected for processing (4)
  • apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts
  • apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
  • apps/web/lib/actions/partners/merge-partner-accounts.ts
  • apps/web/lib/cron/qstash-workflow.ts
💤 Files with no reviewable changes (1)
  • apps/web/app/(ee)/api/cron/partners/merge-accounts/route.ts

Comment thread apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
Comment thread apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
Comment thread apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
Comment thread apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts
Comment thread apps/web/app/(ee)/api/workflows/merge-partner-account/route.ts Outdated
Comment thread apps/web/lib/actions/partners/merge-partner-accounts.ts
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review. any breaking changes or security issues?

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Get full PR diff
gh pr diff 4047 --repo dubinc/dub 2>/dev/null

Length of output: 51517


🏁 Script executed:

#!/bin/bash
# Find and read the new workflow route
find . -path "*workflows/merge-partner-account/route.ts" 2>/dev/null | head -3
find . -path "*merge-partner-account*" 2>/dev/null

# Check how other workflow routes handle auth
find . -path "*workflows*" -name "route.ts" 2>/dev/null | head -10

Length of output: 1082



@pepeladeira Here is the updated full review focused on breaking changes and security:


Breaking Changes

None. The public API contract is unchanged:

Aspect Status
/api/cron/partners/merge-accounts removed Was internal-only (QStash-triggered, signature-protected). No external consumers.
/api/workflows/merge-partner-account added New internal endpoint; same input schema {userId, sourceEmail, targetEmail}.
mergeAccounts server action Trigger mechanism changed from qstash.publishJSON to triggerQStashWorkflow — same behavior to callers.
Prisma schema No changes.
Environment variables No new variables required.

Security Review

✅ Authentication on the new workflow route

The new route uses serve() from @upstash/workflow/nextjs, which automatically verifies QStash signatures — the same pattern used by the existing partner-approved and create-partner-commission workflow routes. No regression vs. the old verifyQstashSignature call.

✅ E2E trigger endpoint is properly sandboxed

/api/e2e/trigger-merge-account has three layers of protection:

  1. withWorkspace auth middleware (requires valid session + workspaces.write permission)
  2. assertE2EWorkspace(workspace) — blocks non-test workspaces
  3. Validates both partner emails are enrolled in ACME_PROGRAM_ID before triggering

This endpoint cannot be used to trigger merges in production.

✅ SQL injection — safe

await conn.execute(`DELETE FROM Partner WHERE id = ?`, [sourcePartnerId]);

Parameterized query. sourcePartnerId comes from prisma.partner.findMany output, not user input.

✅ No sensitive data exposure

loadMergePlan's proceed: false reasons include email addresses, but these are only visible inside the QStash workflow execution context — never returned to the client.

✅ Concurrent merge prevention

flowControl: { key: userId, parallelism: 1 } ensures only one merge workflow runs per user at a time, preventing race conditions.

🟡 failureFunction payload parsing edge case

// initialPayloadParser (correct — raw string → JSON → Zod):
initialPayloadParser: (requestPayload) => inputSchema.parse(JSON.parse(requestPayload))

// failureFunction (no JSON.parse):
const { userId } = inputSchema.parse(context.requestPayload);

In the Upstash SDK, context.requestPayload in failureFunction is already the typed Input object (post-parse), so Zod validates it fine. However, if initialPayloadParser itself throws due to malformed JSON, context.requestPayload would be a raw string and inputSchema.parse(rawString) would fail — meaning the failure handler itself could error out and skip clearing the Redis cache. Low-probability edge case, but worth a defensive JSON.parse try/catch:

failureFunction: async ({ context, ... }) => {
  let userId: string | null = null;
  try {
    const parsed = typeof context.requestPayload === "string"
      ? JSON.parse(context.requestPayload)
      : context.requestPayload;
    userId = inputSchema.parse(parsed).userId;
  } catch {}

  if (userId) {
    await redis.del(`${CACHE_KEY_PREFIX}:${userId}`);
  }
  // ...
}

🟡 R2 key derivation from DB value

sourceImage.replace(`${R2_URL}/`, "")

If sourceImage in the DB doesn't start with R2_URL (e.g., an external URL from an older record), the full URL string is passed as the R2 key, silently failing or potentially deleting an unexpected key. This is the same pattern as the deleted cron route — not a regression — but worth adding a guard:

if (sourceImage?.startsWith(R2_URL)) {
  await storage.delete({ key: sourceImage.replace(`${R2_URL}/`, "") });
}

Summary

The PR introduces no breaking changes and no new security vulnerabilities. The shift from a plain QStash cron job to a durable QStash Workflow is an improvement in reliability (per-step retries, idempotent steps). The two minor issues above (failureFunction edge case and R2 path guard) are pre-existing patterns, not regressions introduced here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants