Skip to content

Move reward enrollment sync to cron#3923

Merged
steven-tey merged 13 commits into
mainfrom
reward-creation-deletion-cron
May 21, 2026
Merged

Move reward enrollment sync to cron#3923
steven-tey merged 13 commits into
mainfrom
reward-creation-deletion-cron

Conversation

@pepeladeira

@pepeladeira pepeladeira commented May 18, 2026

Copy link
Copy Markdown
Collaborator

Summary by CodeRabbit

  • New Features

    • Partners receive transactional emails when rewards are added, updated, or removed.
    • Added a background sync endpoint to process reward enrollment syncs in batches.
  • Improvements

    • Reward enrollment synchronization runs asynchronously in deterministic batches until complete.
    • Creating, updating, or deleting rewards enqueues background sync jobs and triggers partner notifications.
    • Rewards can exist without an associated program for greater data flexibility.
  • UI

    • Improved reward editor handling for modifiers, amounts, durations, and currency inputs.

Review Change Stack

@vercel

vercel Bot commented May 18, 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 May 21, 2026 11:11pm

Request Review

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds queued, batched reward-enrollment synchronization via a Qstash-verified cron POST endpoint, partner notification helpers and email template, makes Reward.program optional, integrates queueing into reward create/delete/update actions, and adjusts UI defaults for reward modifiers.

Changes

Reward Enrollment Async Sync & Partner Notification System

Layer / File(s) Summary
Data Model & Queuing Contracts
packages/prisma/schema/reward.prisma, apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts
Reward.programId and Reward.program are now nullable. New RewardSnapshot and QueueRewardEnrollmentSyncProps interfaces and queueRewardEnrollmentSync publisher define the queue job shape with optional cursor and snapshot.
Enrollment Sync Cron Endpoint
apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
New POST handler (dynamic = "force-dynamic") verifies Qstash signatures, processes deterministic ordered batches of programEnrollment rows, sets/clears reward columns, enqueues continuation jobs when matches remain, and finalizes with partner notifications or reward deletion.
Partner Notification Infrastructure
apps/web/lib/api/partners/notify-partners-reward-changed.ts, packages/email/src/templates/partner-rewards-updated.tsx, apps/web/lib/email/email-templates-map.ts
Adds getRewardEmailSnapshot and notifyPartnersRewardChanged to build snapshots, load program/partner enrollments/users, and queue PartnerRewardsUpdated emails (forwards idempotencyKey). Registers the new email template and its mapping entry.
Reward Action Integration
apps/web/lib/actions/partners/create-reward.ts, apps/web/lib/actions/partners/delete-reward.ts, apps/web/lib/actions/partners/update-reward.ts
Create now enqueues enrollment-sync action: "create" after transaction. Delete computes a rewardSnapshot, nulls group FK and sets reward.programId = null, then enqueues action: "delete". Update conditionally calls notifyPartnersRewardChanged with an idempotency key on group presence.
UI: Modifiers Default Values
apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
Reworks modifiers defaultValues mapping with an Array.isArray guard, fallback maxDuration, guarded currency conversion, and normalization of amountInCents/amountInPercentage.

Sequence Diagram(s)

sequenceDiagram
  participant Qstash
  participant SyncEndpoint as POST /api/cron/rewards/sync-enrollments
  participant DB as Prisma_programEnrollment
  participant RewardDB as Prisma_reward
  participant Queue as queueRewardEnrollmentSync
  participant Notify as notifyPartnersRewardChanged
  Qstash->>SyncEndpoint: Publish with signature & payload
  SyncEndpoint->>SyncEndpoint: Verify Qstash signature & parse JSON
  SyncEndpoint->>DB: Fetch ordered batch by programEnrollment.id
  SyncEndpoint->>DB: updateMany to set/clear reward column
  SyncEndpoint->>DB: Count remaining matches
  alt Remaining enrollments exist
    SyncEndpoint->>Queue: Publish next batch with startAfterProgramEnrollmentId
  else All processed
    alt action == delete
      SyncEndpoint->>RewardDB: Hard delete reward (P2025 treated as success)
      SyncEndpoint->>Notify: Notify partners (removed)
    else action == create
      SyncEndpoint->>RewardDB: Fetch reward
      SyncEndpoint->>Notify: Notify partners (added)
    end
    Notify->>DB: Load program & partner enrollments
    Notify->>DB: Fetch partner users
    Notify->>Notify: Queue PartnerRewardsUpdated per user
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • dubinc/dub#3858: Overlaps reward action modules and reward handling paths, likely related to reward/referral behavior changes.

Suggested reviewers

  • steven-tey
  • devkiran

Poem

🐰 I hopped through queues and batches bright,
I nudged rewards from dark to light,
Snapshots bundled, emails spun,
Partners told when work was done,
A tiny rabbit's sync — all right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.67% 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 PR title 'Move reward enrollment sync to cron' directly and accurately summarizes the main architectural change: reward enrollment synchronization is being moved from synchronous processing to asynchronous cron-based processing via QStash.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch reward-creation-deletion-cron

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: 4

🤖 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/rewards/sync-enrollments/route.ts:
- Line 1: The route is using the generic handleAndReturnErrorResponse which
breaks cron/QStash retry semantics; replace that with the cron-specific handler
handleCronErrorResponse: update the import to bring in handleCronErrorResponse
instead of handleAndReturnErrorResponse and change any call sites in this file
(including the block around the current 186-193 region) to invoke
handleCronErrorResponse so errors are routed through the cron-standard path and
QStash retry behavior is preserved.

In `@apps/web/lib/actions/partners/create-reward.ts`:
- Around line 111-118: The post-commit call to queueRewardEnrollmentSync can
leave the system in a partially-applied state if enqueue fails; modify the flow
to use a durable enqueue pattern: either write an outbox record in the same DB
transaction that creates/updates the reward (e.g., add an Outbox/Job row as part
of the createReward transaction) or atomically set a persistent flag like
reward.enrollmentSyncPending and have a reliable background reconciler scan and
enqueue retries; replace the direct await queueRewardEnrollmentSync(...) call
with creation of an outbox entry (or set the pending flag) inside the same
transaction and implement a separate worker/process to read the outbox and call
queueRewardEnrollmentSync to ensure guaranteed delivery and recovery.

In `@apps/web/lib/actions/partners/delete-reward.ts`:
- Around line 65-72: The delete flow currently clears the partnerGroup link and
nulls reward.programId then calls queueRewardEnrollmentSync, which can fail and
leave the DB in an intermediate state; fix by making the enqueue operation
atomic with the DB change—either perform queueRewardEnrollmentSync before
mutating links and abort the delete on enqueue failure, or implement a
transactional outbox: insert a sync job row (e.g.,
createRewardEnrollmentSyncJob) inside the same transaction that updates
partnerGroup/reward, and have a post-commit worker pick up that outbox row to
call queueRewardEnrollmentSync; update the delete-reward logic (references:
queueRewardEnrollmentSync, partnerGroup, reward.programId) to use one of these
patterns so a publish failure cannot strand detached rewards.

In `@apps/web/lib/actions/partners/update-reward.ts`:
- Around line 160-168: The call to notifyPartnersRewardChanged inside
updateRewardAction should be removed so partner notifications are not fired
before enrollment sync finalization; instead, emit the notification only from
the cron sync finalization path where you detect "no remaining work" (the branch
in the cron job that finalizes a sync), using the same payload shape (programId,
groupId: partnerGroup.id, action: "updated", effectiveAt:
updatedReward.updatedAt, reward: serializeReward(rewardMetadata)) and keep an
idempotencyKey (e.g., `reward-sync-${rewardId}-updated`) to prevent duplicates;
update the code by deleting or commenting out the notifyPartnersRewardChanged
call in updateRewardAction and adding the notification invocation into the cron
finalization branch that confirms enrollment sync completion.
🪄 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: 8f87ce71-a309-44c9-8265-a90b8ddf0a8a

📥 Commits

Reviewing files that changed from the base of the PR and between 2a12b1a and 73139d4.

📒 Files selected for processing (9)
  • apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
  • apps/web/lib/actions/partners/create-reward.ts
  • apps/web/lib/actions/partners/delete-reward.ts
  • apps/web/lib/actions/partners/update-reward.ts
  • apps/web/lib/api/partners/notify-partners-reward-changed.ts
  • apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts
  • apps/web/lib/email/email-templates-map.ts
  • packages/email/src/templates/partner-rewards-updated.tsx
  • packages/prisma/schema/reward.prisma

Comment thread apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
Comment thread apps/web/lib/actions/partners/create-reward.ts
Comment thread apps/web/lib/actions/partners/delete-reward.ts
Comment thread apps/web/lib/actions/partners/update-reward.ts
@pepeladeira pepeladeira marked this pull request as ready for review May 19, 2026 15:12
@pepeladeira

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@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

🤖 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/rewards/sync-enrollments/route.ts:
- Around line 127-155: When deleting rewards in the delete branch, make the
delete-finalization retry-safe by treating "record not found" as a successful
delete instead of returning early: inside the prisma.reward.delete error handler
(the try/catch around prisma.reward.delete for rewardId) detect the not-found
error and continue to the notifyPartnersRewardChanged call; only call
logAndRespond/return for unexpected errors. Keep references to the existing
functions/variables: prisma.reward.delete, notifyPartnersRewardChanged, log, and
logAndRespond so the retry will still send the removal notification even if the
reward was already removed.

In `@apps/web/lib/actions/partners/update-reward.ts`:
- Around line 160-168: The idempotency key used in the
notifyPartnersRewardChanged call is static per reward edit and causes Resend
dedup conflicts; update the idempotencyKey generation inside the
notifyPartnersRewardChanged invocation (where notifyPartnersRewardChanged is
called with programId, groupId: partnerGroup.id, action, effectiveAt:
updatedReward.updatedAt, reward, ...) to include the update timestamp (use
updatedReward.updatedAt.getTime() or similar) so the key becomes unique per
update (e.g., `reward-sync-${rewardId}-${updatedReward.updatedAt.getTime()}`).
🪄 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: dd8bd531-c099-438f-9dc2-54d55f384a5a

📥 Commits

Reviewing files that changed from the base of the PR and between e57e1b4 and 80e2063.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
  • apps/web/lib/actions/partners/create-reward.ts
  • apps/web/lib/actions/partners/delete-reward.ts
  • apps/web/lib/actions/partners/update-reward.ts
  • apps/web/lib/api/partners/get-group-rewards-and-bounties.ts
  • apps/web/lib/api/partners/notify-partners-reward-changed.ts
  • apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts
  • apps/web/lib/email/email-templates-map.ts
  • packages/email/src/templates/partner-rewards-updated.tsx
  • packages/prisma/schema/reward.prisma

Comment thread apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
Comment thread apps/web/lib/actions/partners/update-reward.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.

🧹 Nitpick comments (1)
apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts (1)

11-19: ⚡ Quick win

Tighten the queue contract so delete jobs always include rewardSnapshot.

Right now, Line 18 and Line 45 make rewardSnapshot optional, so action: "delete" can compile with an incomplete payload. A discriminated union here prevents invalid jobs at compile time.

Proposed diff
 export interface RewardSnapshot {
   title: string;
   description: string;
   icon: string;
 }
 
-export interface QueueRewardEnrollmentSyncProps {
-  action: "create" | "delete";
-  rewardId: string;
-  groupId: string;
-  programId: string;
-  event: EventType;
-  startAfterProgramEnrollmentId?: string;
-  rewardSnapshot?: RewardSnapshot;
-}
+type QueueRewardEnrollmentSyncBase = {
+  rewardId: string;
+  groupId: string;
+  programId: string;
+  event: EventType;
+  startAfterProgramEnrollmentId?: string;
+};
+
+export type QueueRewardEnrollmentSyncProps =
+  | (QueueRewardEnrollmentSyncBase & {
+      action: "create";
+      rewardSnapshot?: never;
+    })
+  | (QueueRewardEnrollmentSyncBase & {
+      action: "delete";
+      rewardSnapshot: RewardSnapshot;
+    });

Also applies to: 38-46

🤖 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/api/rewards/queue-reward-enrollment-sync.ts` around lines 11 -
19, Replace the loose interface QueueRewardEnrollmentSyncProps with a
discriminated union so TypeScript enforces that action === "delete" always
includes rewardSnapshot while action === "create" may omit it; specifically
declare two variants (e.g., { action: "create"; rewardSnapshot?: RewardSnapshot;
... } and { action: "delete"; rewardSnapshot: RewardSnapshot; ... }) using the
same common fields (rewardId, groupId, programId, event,
startAfterProgramEnrollmentId) and update any producers/consumers of
QueueRewardEnrollmentSyncProps (enqueue calls and handlers) to satisfy the
stricter shape for "delete" jobs.
🤖 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.

Nitpick comments:
In `@apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts`:
- Around line 11-19: Replace the loose interface QueueRewardEnrollmentSyncProps
with a discriminated union so TypeScript enforces that action === "delete"
always includes rewardSnapshot while action === "create" may omit it;
specifically declare two variants (e.g., { action: "create"; rewardSnapshot?:
RewardSnapshot; ... } and { action: "delete"; rewardSnapshot: RewardSnapshot;
... }) using the same common fields (rewardId, groupId, programId, event,
startAfterProgramEnrollmentId) and update any producers/consumers of
QueueRewardEnrollmentSyncProps (enqueue calls and handlers) to satisfy the
stricter shape for "delete" jobs.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1d599d0f-8eaf-4f70-908b-f8bebbc59c75

📥 Commits

Reviewing files that changed from the base of the PR and between e87d4b1 and c2d42a6.

📒 Files selected for processing (2)
  • apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
  • apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts
💤 Files with no reviewable changes (1)
  • apps/web/app/(ee)/api/cron/rewards/sync-enrollments/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: 1

🧹 Nitpick comments (1)
apps/web/lib/api/partners/notify-partners-reward-changed.ts (1)

82-113: 💤 Low value

Consider early return when no partners to notify.

If program.partners is empty (no partners in the group with valid emails), queueBatchEmail will be called with an empty array. Adding a guard improves clarity and avoids an unnecessary call.

♻️ Suggested change
   const partnersToNotify = program.partners.map(({ partner }) => partner);
+
+  if (partnersToNotify.length === 0) {
+    console.log(`No partners to notify for program ${programId} and group ${groupId}.`);
+    return;
+  }

   await queueBatchEmail<typeof PartnerRewardsUpdated>(
🤖 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/api/partners/notify-partners-reward-changed.ts` around lines 82
- 113, Check for an empty recipients list before calling queueBatchEmail:
compute partnersToNotify (derived from program.partners) and if it's empty (or
contains no partners with valid emails) return early instead of invoking
queueBatchEmail with an empty array; update the logic around the
partnersToNotify variable and the queueBatchEmail<typeof PartnerRewardsUpdated>
call to short-circuit when there are no recipients.
🤖 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/ui/partners/rewards/add-edit-reward-sheet.tsx`:
- Around line 238-262: The computed maxDuration defaults currently let a missing
m.maxDuration pick up a null from defaultValuesSource and later leave it as
null; update the calculation so when m.maxDuration === undefined you treat
defaultValuesSource?.maxDuration === null (or undefined) as Infinity: e.g.
compute const maxDuration = m.maxDuration === undefined ?
(defaultValuesSource?.maxDuration == null ? Infinity :
defaultValuesSource.maxDuration) : m.maxDuration and then keep the final
property normalization (or simply set maxDuration: maxDuration) so any inherited
null becomes Infinity; adjust references in the modifier mapping where
maxDuration is used.

---

Nitpick comments:
In `@apps/web/lib/api/partners/notify-partners-reward-changed.ts`:
- Around line 82-113: Check for an empty recipients list before calling
queueBatchEmail: compute partnersToNotify (derived from program.partners) and if
it's empty (or contains no partners with valid emails) return early instead of
invoking queueBatchEmail with an empty array; update the logic around the
partnersToNotify variable and the queueBatchEmail<typeof PartnerRewardsUpdated>
call to short-circuit when there are no recipients.
🪄 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: eea00e12-b6f5-4a81-a24f-2c275877656b

📥 Commits

Reviewing files that changed from the base of the PR and between c2d42a6 and bf4cd35.

📒 Files selected for processing (3)
  • apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
  • apps/web/lib/api/partners/notify-partners-reward-changed.ts
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx

Comment thread apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
@steven-tey

Copy link
Copy Markdown
Collaborator

@coderabbitai full review, any breaking changes?

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for new Prisma migration files
fd --type f --extension sql . packages/prisma/migrations | sort | tail -5

# Check the reward schema change
cat packages/prisma/schema/reward.prisma 2>/dev/null || fd -i "reward.prisma" --exec cat {}

# Check the new cron route
cat apps/web/app/\(ee\)/api/cron/rewards/sync-enrollments/route.ts 2>/dev/null || fd -i "route.ts" --full-path "*sync-enrollments*" --exec cat {}

# Check the delete-reward action
cat apps/web/lib/actions/partners/delete-reward.ts 2>/dev/null || fd -i "delete-reward.ts" --exec cat {}

# Check the create-reward action
cat apps/web/lib/actions/partners/create-reward.ts 2>/dev/null || fd -i "create-reward.ts" --exec cat {}

# Check the queue utility
cat apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts 2>/dev/null || fd -i "queue-reward-enrollment-sync.ts" --exec cat {}

Length of output: 15427


🏁 Script executed:

#!/bin/bash
# Search for migration files anywhere in the repo
fd --type f --extension sql | head -20

# Also check if there's a prisma directory with migrations
fd --type d "migrations" | head -10

# Check the prisma schema directory structure
fd --type f "*.prisma" | head -20

# Check if there's a migration for the reward programId change
fd --type f --extension sql --exec grep -l "programId" {} \; 2>/dev/null | head -10
rg -l "programId" --type sql 2>/dev/null | head -10

Length of output: 317


🏁 Script executed:

#!/bin/bash
# Check the migrations directory contents
ls -la apps/web/scripts/migrations/ 2>/dev/null

# Find Prisma schema files
fd --fixed-strings ".prisma" | head -20

# Check if there's a dedicated Prisma migrations folder under packages/prisma
ls packages/prisma/ 2>/dev/null

# Look for any migration-related files added in this PR
git diff --name-only origin/main...HEAD 2>/dev/null | head -30

# Check for any new migration files
git diff --name-only origin/main...HEAD 2>/dev/null | grep -i migrat

Length of output: 1767


@steven-tey — here's a summary of the breaking changes and notable risks in this PR:


⚠️ Breaking Changes

1. Prisma Schema Change — Reward.programId is now nullable (String?)

This is a database migration required change. The PR modifies packages/prisma/schema/reward.prisma to make programId nullable, but no migration file is included in the diff. The packages/prisma package has no migrations/ folder and apps/web/scripts/migrations/ has no reward-related script either. Deploying this without a corresponding DB migration (e.g. ALTER TABLE Reward MODIFY programId VARCHAR(255) NULL) will cause a Prisma schema-to-DB mismatch.

2. Enrollment Sync is Now Eventually Consistent

Both createRewardAction and deleteRewardAction no longer update programEnrollment rows synchronously. Instead, they enqueue a QStash job and return. This is a behavioral breaking change:

  • After createRewardAction completes, partner enrollments do not yet have the new reward assigned.
  • After deleteRewardAction completes, partner enrollments still have the old reward until the cron job processes them.
  • Any downstream code, tests, or integrations that assume immediate consistency (e.g. querying enrollment state right after creating/deleting a reward) will observe stale data.

3. Reward Records Have a Transient "Orphaned" State

deleteRewardAction now sets reward.programId = null in the transaction (instead of hard-deleting the reward). The actual prisma.reward.delete() only happens at the end of the cron job, after all enrollments are cleared. During this window:

  • The Reward row exists in the DB with programId = null.
  • Any query not filtering by programId could surface these orphaned rewards.
  • If the cron job fails mid-way, the reward will be left in this limbo state indefinitely.

4. QStash is Now on the Critical Path for Reward Lifecycle

queueRewardEnrollmentSync is await-ed directly (not inside waitUntil) in both actions. This is correct fail-fast behavior — if QStash publishing fails, the action throws. However, this also means:

  • Any QStash outage or rate-limit will prevent reward creation/deletion from succeeding entirely.
  • The new flowControl: { key: groupId, rate: 1 } setting ensures only 1 concurrent job per group, but means jobs for the same group queue up and may lag.

Summary Table

Change Type Risk
Reward.programId nullable, no migration Schema 🔴 High — deploy blocker
Enrollment sync moved to cron Behavioral 🟠 Medium — eventual consistency
Reward "orphan" state during deletion Data integrity 🟠 Medium — limbo state on cron failure
QStash on critical path Infrastructure 🟡 Low-Medium — correct fail-fast, but adds external dependency
✅ Actions performed

Full review triggered.

@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: 1

🧹 Nitpick comments (2)
apps/web/lib/api/partners/notify-partners-reward-changed.ts (1)

82-113: 💤 Low value

Consider early-returning when no partners to notify.

If program.partners is empty (no partners match groupId + non-null email), partnersToNotify is an empty array. The code still calls queueBatchEmail with an empty array, which may be a silent no-op or unnecessary API call. Adding an early return with a log would improve debuggability.

♻️ Suggested improvement
   const partnersToNotify = program.partners.map(({ partner }) => partner);

+  if (partnersToNotify.length === 0) {
+    console.log(
+      `No partners to notify for program ${programId} and group ${groupId}.`,
+    );
+    return;
+  }
+
   await queueBatchEmail<typeof PartnerRewardsUpdated>(
🤖 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/api/partners/notify-partners-reward-changed.ts` around lines 82
- 113, partnersToNotify is built from program.partners and may be empty; avoid
calling queueBatchEmail with an empty array by adding an early return: after
computing partnersToNotify (from program.partners.map(...)) check if
partnersToNotify.length === 0, log a debug/info message indicating no partners
to notify (include program.id or program.name for context) and return early, so
queueBatchEmail is only invoked when there are recipients.
packages/email/src/templates/partner-rewards-updated.tsx (1)

114-127: 💤 Low value

Potential React key collision when same title appears multiple times.

The key ${change.action}-${change.title} could collide if the same action group contains multiple changes with identical titles. Consider adding an index or unique identifier.

♻️ Suggested fix
-                    {items.map((change) => (
+                    {items.map((change, idx) => (
                       <Row
-                        key={`${change.action}-${change.title}`}
+                        key={`${change.action}-${change.title}-${idx}`}
                         className="mb-0 mt-2"
                       >
🤖 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-rewards-updated.tsx` around lines 114 -
127, The mapped list in items.map uses a fragile key
`${change.action}-${change.title}` which can collide when titles repeat; update
the Row key to include a stable unique discriminator (e.g., the item's id or the
map index) so keys are unique and stable—locate the items.map rendering in
partner-rewards-updated.tsx (the Row element inside items.map) and change the
key to something like `${change.action}-${change.title}-${change.id}` or
`${change.action}-${change.title}-${index}` depending on whether a persistent id
field (change.id) exists, ensuring no duplicate keys.
🤖 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/rewards/sync-enrollments/route.ts:
- Around line 139-154: Add a proper type guard before accessing error.code:
import Prisma and update the catch to first check whether the caught error is a
Prisma.PrismaClientKnownRequestError (or use a user-defined type guard that
asserts 'code' exists) and only then compare error.code to "P2025"; otherwise
treat it as a non-Prisma error, log it with log (including error.message or the
whole error) and call logAndRespond for rewardId as currently done. Ensure you
reference Prisma.PrismaClientKnownRequestError (or your guard) when narrowing
the error type so TypeScript stops complaining about error.code.

---

Nitpick comments:
In `@apps/web/lib/api/partners/notify-partners-reward-changed.ts`:
- Around line 82-113: partnersToNotify is built from program.partners and may be
empty; avoid calling queueBatchEmail with an empty array by adding an early
return: after computing partnersToNotify (from program.partners.map(...)) check
if partnersToNotify.length === 0, log a debug/info message indicating no
partners to notify (include program.id or program.name for context) and return
early, so queueBatchEmail is only invoked when there are recipients.

In `@packages/email/src/templates/partner-rewards-updated.tsx`:
- Around line 114-127: The mapped list in items.map uses a fragile key
`${change.action}-${change.title}` which can collide when titles repeat; update
the Row key to include a stable unique discriminator (e.g., the item's id or the
map index) so keys are unique and stable—locate the items.map rendering in
partner-rewards-updated.tsx (the Row element inside items.map) and change the
key to something like `${change.action}-${change.title}-${change.id}` or
`${change.action}-${change.title}-${index}` depending on whether a persistent id
field (change.id) exists, ensuring no duplicate keys.
🪄 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: 9759412b-a10e-41ea-9dd4-7036ac5b47ea

📥 Commits

Reviewing files that changed from the base of the PR and between 56a6952 and bf4cd35.

📒 Files selected for processing (10)
  • apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
  • apps/web/lib/actions/partners/create-reward.ts
  • apps/web/lib/actions/partners/delete-reward.ts
  • apps/web/lib/actions/partners/update-reward.ts
  • apps/web/lib/api/partners/notify-partners-reward-changed.ts
  • apps/web/lib/api/rewards/queue-reward-enrollment-sync.ts
  • apps/web/lib/email/email-templates-map.ts
  • apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx
  • packages/email/src/templates/partner-rewards-updated.tsx
  • packages/prisma/schema/reward.prisma

Comment thread apps/web/app/(ee)/api/cron/rewards/sync-enrollments/route.ts
@steven-tey steven-tey merged commit 1c41db2 into main May 21, 2026
11 checks passed
@steven-tey steven-tey deleted the reward-creation-deletion-cron branch May 21, 2026 23:26
This was referenced Jun 11, 2026
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