-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Improve Rewardful importer to support multi-campaign import #2932
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughConverts Rewardful imports from a single-campaign flow to multi-campaign: replaces importCampaign with importCampaigns, changes payloads/schemas to use campaignIds[], updates importers (partners/customers/commissions), adjusts API and UI modal for multi-select, and adds a ScrollContainer component. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Admin as Admin User
participant Modal as ImportRewardfulModal
participant Actions as start-rewardful-import
participant Queue as Cron Job Queue (/api/cron/import/rewardful)
participant Importer as rewardfulImporter
participant RWAPI as RewardfulApi
participant DB as Prisma
Admin->>Modal: open modal & select campaigns
Modal->>Actions: call start-rewardful-import (action: import-campaigns, campaignIds)
Actions->>Queue: enqueue job (import-campaigns, campaignIds)
Queue->>Importer: process job -> importCampaigns(payload)
Importer->>RWAPI: list campaigns
RWAPI-->>Importer: campaigns[]
Importer->>Importer: filter campaigns by campaignIds
loop per campaign
Importer->>DB: upsert partner group
DB-->>Importer: group upserted
alt no saleRewardId
Importer->>DB: create sale reward
DB-->>Importer: reward created
end
alt campaign.default == true
Importer->>DB: update program payout defaults
DB-->>Importer: program updated
end
end
Importer->>Importer: queue import-partners (campaignIds)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/lib/rewardful/import-partners.ts (1)
62-115: Stop logging every non-selected affiliate as an errorBecause
listPartnersnow pulls every affiliate across all campaigns, theelsebranch fires for affiliates that simply aren’t in the requestedcampaignIds, and we record anINACTIVE_PARTNERerror for each of them. That regresses the previous behaviour (where the API filtered by campaign) and will flood the importer error log with false failures whenever someone imports only a subset of campaigns.Filter down to the selected campaign IDs before splitting into “active” versus “skipped” affiliates so we only log genuine in-campaign skips, and update the message accordingly.
- const activeAffiliates: typeof affiliates = []; - const notImportedAffiliates: typeof affiliates = []; - - for (const affiliate of affiliates) { - if ( - affiliate.state === "active" && - affiliate.leads > 0 && - campaignIds.includes(affiliate.campaign.id) - ) { - activeAffiliates.push(affiliate); - } else { - notImportedAffiliates.push(affiliate); - } - } + const affiliatesForSelectedCampaigns = affiliates.filter((affiliate) => + campaignIds.includes(affiliate.campaign.id), + ); + + const activeAffiliates = affiliatesForSelectedCampaigns.filter( + (affiliate) => affiliate.state === "active" && affiliate.leads > 0, + ); + + const notImportedAffiliates = affiliatesForSelectedCampaigns.filter( + (affiliate) => affiliate.state !== "active" || affiliate.leads <= 0, + ); @@ - message: `Partner ${affiliate.email} not imported because it is not active or has no leads or is not in selected campaignIds (${campaignIds.join(", ")}).`, + message: `Partner ${affiliate.email} not imported because it is not active or has no leads.`,apps/web/lib/rewardful/import-commissions.ts (1)
29-85: Guard against missingcampaignIdsin legacy payloads.Line 29 introduces
campaignIdsfrom the payload, and Line 159 immediately callscampaignIds.includes(...). Jobs queued before this deploy (or any fallback caller that still sends the singlecampaignIdpayload) will arrive withoutcampaignIds; at runtime that turns intoundefined.includes(...), throwing and killing the whole import batch. Please make the filter tolerant ofcampaignIdsbeing absent by defaulting to “import everything” unless the array is present, e.g. only run theincludescheck whencampaignIds?.lengthis truthy and type the argument accordingly.- createCommission({ + createCommission({ commission, program, - campaignIds, + campaignIds,-async function createCommission({ - commission, - program, - campaignIds, +async function createCommission({ + commission, + program, + campaignIds = [],- campaignIds: string[]; + campaignIds?: string[];- if (commission.campaign.id && !campaignIds.includes(commission.campaign.id)) { + if ( + campaignIds.length && + commission.campaign.id && + !campaignIds.includes(commission.campaign.id) + ) {Also applies to: 135-165
🧹 Nitpick comments (1)
packages/ui/src/scroll-container.tsx (1)
5-32: Consider making dimensions and fade color configurable.The component has several hard-coded values that limit its reusability:
- Hard-coded height (
h-[190px]at line 17): Different use cases may need different heights- Full viewport width on mobile (
w-screenat line 17): This could cause layout issues if the component is nested within a container with padding or constraints- Hard-coded white gradient (
from-whiteat line 27): This won't work with dark mode or non-white backgroundsConsider accepting
height,width, andfadeColorprops to make the component more flexible, or allow these styles to be fully controlled via theclassNameprop.Example refactor to accept a height prop:
export function ScrollContainer({ children, className, -}: PropsWithChildren<{ className?: string }>) { + height = "190px", +}: PropsWithChildren<{ className?: string; height?: string }>) { const ref = useRef<HTMLDivElement>(null); const { scrollProgress, updateScrollProgress } = useScrollProgress(ref); return ( <div className="relative"> <div className={cn( - "scrollbar-hide h-[190px] w-screen overflow-y-scroll sm:w-auto", + "scrollbar-hide overflow-y-scroll w-screen sm:w-auto", className, )} + style={{ height }} ref={ref} onScroll={updateScrollProgress} >For the fade color, consider using CSS variables or accepting a prop for dark mode support.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
apps/web/app/(ee)/api/cron/import/rewardful/route.ts(2 hunks)apps/web/lib/actions/partners/start-rewardful-import.ts(2 hunks)apps/web/lib/rewardful/api.ts(1 hunks)apps/web/lib/rewardful/import-campaign.ts(0 hunks)apps/web/lib/rewardful/import-campaigns.ts(1 hunks)apps/web/lib/rewardful/import-commissions.ts(4 hunks)apps/web/lib/rewardful/import-customers.ts(3 hunks)apps/web/lib/rewardful/import-partners.ts(4 hunks)apps/web/lib/rewardful/schemas.ts(2 hunks)apps/web/lib/rewardful/types.ts(2 hunks)apps/web/ui/modals/import-rewardful-modal.tsx(10 hunks)apps/web/ui/partners/groups/groups-multi-select.tsx(3 hunks)packages/ui/src/index.tsx(1 hunks)packages/ui/src/scroll-container.tsx(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/lib/rewardful/import-campaign.ts
🧰 Additional context used
🧬 Code graph analysis (8)
apps/web/lib/rewardful/import-campaigns.ts (4)
apps/web/lib/rewardful/types.ts (1)
RewardfulImportPayload(103-105)apps/web/lib/rewardful/api.ts (1)
RewardfulApi(20-96)apps/web/lib/api/create-id.ts (1)
createId(65-70)apps/web/lib/rewardful/import-campaign.ts (1)
importCampaign(11-125)
apps/web/ui/partners/groups/groups-multi-select.tsx (1)
packages/ui/src/scroll-container.tsx (1)
ScrollContainer(5-32)
apps/web/lib/rewardful/import-commissions.ts (1)
apps/web/lib/rewardful/types.ts (1)
RewardfulCommission(89-101)
apps/web/app/(ee)/api/cron/import/rewardful/route.ts (1)
apps/web/lib/rewardful/import-campaigns.ts (1)
importCampaigns(11-118)
apps/web/lib/rewardful/import-customers.ts (1)
apps/web/lib/rewardful/types.ts (1)
RewardfulReferral(58-70)
apps/web/ui/modals/import-rewardful-modal.tsx (3)
apps/web/lib/rewardful/types.ts (1)
RewardfulCampaign(11-25)packages/ui/src/toggle-group.tsx (1)
ToggleGroup(15-83)packages/ui/src/scroll-container.tsx (1)
ScrollContainer(5-32)
packages/ui/src/scroll-container.tsx (1)
packages/ui/src/hooks/use-scroll-progress.ts (1)
useScrollProgress(6-37)
apps/web/lib/actions/partners/start-rewardful-import.ts (1)
apps/web/lib/actions/safe-action.ts (1)
authActionClient(33-82)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (3)
packages/ui/src/index.tsx (1)
34-34: LGTM!The export follows the established pattern and is correctly positioned alphabetically.
apps/web/ui/partners/groups/groups-multi-select.tsx (1)
10-10: LGTM! Clean refactor to shared component.The transition from the custom
Scrollcomponent to the sharedScrollContainerfrom@dub/uiis well-executed. The import cleanup correctly removes now-unused dependencies (PropsWithChildren,useRef), and the ScrollContainer properly wraps the Command.List while maintaining the existing list behavior.Also applies to: 16-16, 156-232
packages/ui/src/scroll-container.tsx (1)
1-3: Remove incorrect syntax error concern: All instances of"vertical"inuse-scroll-progress.tsuse matching quotes, so there’s no compilation issue.Likely an incorrect or invalid review comment.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (2)
packages/ui/src/scroll-container.tsx (1)
5-32: Consider making height configurable.The component works well, but the hardcoded height of
190px(line 17) may not suit all use cases. Consider accepting an optionalheightprop to allow consumers to override this value when needed.Example enhancement:
export function ScrollContainer({ children, className, + height = "h-[190px]", }: PropsWithChildren<{ className?: string }>) { +}: PropsWithChildren<{ className?: string; height?: string }>) { const ref = useRef<HTMLDivElement>(null); const { scrollProgress, updateScrollProgress } = useScrollProgress(ref); return ( <div className="relative"> <div className={cn( - "scrollbar-hide h-[190px] w-screen overflow-y-scroll sm:w-auto", + "scrollbar-hide w-screen overflow-y-scroll sm:w-auto", + height, className, )}apps/web/lib/rewardful/import-campaigns.ts (1)
83-90: Consider explicit reward_type handling.Lines 83-86 map
reward_typewith an implicit fallback toRewardStructure.percentagefor non-"amount" values. While this works for a binary choice, explicitly handling expected values ("amount"and"percentage") with a fallback or error for unexpected values would improve robustness.Apply this diff for more explicit handling:
type: - reward_type === "amount" - ? RewardStructure.flat - : RewardStructure.percentage, + reward_type === "amount" + ? RewardStructure.flat + : reward_type === "percentage" + ? RewardStructure.percentage + : (() => { + console.warn(`Unknown reward_type: ${reward_type}, defaulting to percentage`); + return RewardStructure.percentage; + })(),Alternatively, use a mapping object:
+ const rewardTypeMap = { + amount: RewardStructure.flat, + percentage: RewardStructure.percentage, + } as const; type: - reward_type === "amount" - ? RewardStructure.flat - : RewardStructure.percentage, + rewardTypeMap[reward_type as keyof typeof rewardTypeMap] ?? RewardStructure.percentage,
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (14)
apps/web/app/(ee)/api/cron/import/rewardful/route.ts(2 hunks)apps/web/lib/actions/partners/start-rewardful-import.ts(2 hunks)apps/web/lib/rewardful/api.ts(1 hunks)apps/web/lib/rewardful/import-campaign.ts(0 hunks)apps/web/lib/rewardful/import-campaigns.ts(1 hunks)apps/web/lib/rewardful/import-commissions.ts(4 hunks)apps/web/lib/rewardful/import-customers.ts(3 hunks)apps/web/lib/rewardful/import-partners.ts(4 hunks)apps/web/lib/rewardful/schemas.ts(2 hunks)apps/web/lib/rewardful/types.ts(2 hunks)apps/web/ui/modals/import-rewardful-modal.tsx(10 hunks)apps/web/ui/partners/groups/groups-multi-select.tsx(3 hunks)packages/ui/src/index.tsx(1 hunks)packages/ui/src/scroll-container.tsx(1 hunks)
💤 Files with no reviewable changes (1)
- apps/web/lib/rewardful/import-campaign.ts
🔇 Additional comments (9)
apps/web/lib/rewardful/import-customers.ts (1)
15-15: LGTM! Clean conversion from single to multi-campaign.The transition from
campaignIdtocampaignIdsis correctly implemented: payload destructuring, parameter typing, filtering logic usingincludes(), and logging withjoin()are all appropriate.Also applies to: 50-50, 72-72, 78-78, 84-87
apps/web/ui/partners/groups/groups-multi-select.tsx (1)
10-10: LGTM! Clean refactor to centralized ScrollContainer.The transition from custom
Scrollwrapper toScrollContainersimplifies the code while preserving the scrolling behavior and loading/empty states. The tightened React imports and removal ofuseScrollProgressare appropriate since that logic is now encapsulated inScrollContainer.Also applies to: 16-16, 156-232
apps/web/lib/rewardful/api.ts (1)
54-66: LGTM! API simplified for multi-campaign support.Removing the
campaignIdparameter and addingexpand[]forcampaignmakes sense for the multi-campaign flow, where filtering is now handled downstream inimportPartnersbased oncampaignIds. This simplifies the API surface.apps/web/lib/rewardful/import-commissions.ts (1)
29-29: LGTM! Consistent multi-campaign conversion.The transition to
campaignIdsarray is correctly implemented throughout the commission import flow, matching the pattern used in other import modules.Also applies to: 79-79, 137-137, 145-145, 159-161
apps/web/app/(ee)/api/cron/import/rewardful/route.ts (1)
3-3: LGTM! Route updated for multi-campaign flow.The import binding, action string, and function call are all correctly updated to use the new
importCampaignsmodule.Also applies to: 20-21
apps/web/lib/rewardful/import-partners.ts (2)
12-12: LGTM! Multi-campaign filtering correctly implemented.The conversion to
campaignIds-based filtering is correct, and the three-condition check (active state, has leads, campaign in list) properly restricts import scope. The updated logging message accurately reflects the multi-campaign context.Also applies to: 50-52, 63-67, 113-113
27-32: Slug prefix assumption validated. Verified inimport-campaigns.tsthat all Rewardful groups are upserted withslug = \rewardful-${campaignId}`, so the mapping inimport-partners.ts` is safe.apps/web/lib/rewardful/import-campaigns.ts (2)
25-27: LGTM! Campaign filtering and group creation are correct.The filtering logic correctly restricts import to selected campaigns, and the group creation with
slug: "rewardful-{campaignId}"format matches the expectation inimport-partners.ts(line 29-30), ensuring proper group mapping.Also applies to: 40-56
98-111: Null-check unnecessary: both fields are non‐nullable. Rewardful TS types (number) and Prisma schema (Intnon-nullable) guarantee nonullorundefined.
| campaignIds: z | ||
| .array(z.string()) | ||
| .describe("Rewardful campaign IDs to import."), | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Harden action input: enforce at least one campaign ID
startRewardfulImportAction used to require a campaign via parsedInput.campaignId. With the array form, we should keep that invariant server-side by adding .min(1) to the schema. That way any misuse (tests, future callers, or UI regressions) is caught before queuing an import job.
campaignIds: z
.array(z.string())
.min(1, "Select at least one campaign")
.describe("Rewardful campaign IDs to import."),
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/start-rewardful-import.ts around lines 12 to
15, the campaignIds schema currently allows an empty array; update the Zod
schema to require at least one campaign by adding .min(1, "Select at least one
campaign") to the array chain so the server enforces that callers provide one or
more campaign IDs and validation fails early.
| if (!createdGroup.saleRewardId) { | ||
| const createdReward = await prisma.reward.create({ | ||
| data: { | ||
| id: createId({ prefix: "rw_" }), | ||
| programId, | ||
| // connect the reward to the group | ||
| salePartnerGroup: { | ||
| connect: { | ||
| id: createdGroup.id, | ||
| }, | ||
| }, | ||
| event: EventType.sale, | ||
| maxDuration: max_commission_period_months, | ||
| type: | ||
| reward_type === "amount" | ||
| ? RewardStructure.flat | ||
| : RewardStructure.percentage, | ||
| amount: | ||
| reward_type === "amount" | ||
| ? commission_amount_cents | ||
| : commission_percent, | ||
| }, | ||
| }); | ||
| console.log( | ||
| `Since group was newly created, also created reward ${createdReward.id} with amount ${createdReward.amount} and type ${createdReward.type}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
🧩 Analysis chain
Verify null handling for reward amounts.
Lines 88-90 assign amount based on reward_type, but don't explicitly handle cases where commission_amount_cents or commission_percent might be null or undefined. While Prisma may enforce non-null constraints, it's safer to validate or provide defaults.
Run the following script to check the Rewardful API response schema and Prisma schema:
🏁 Script executed:
#!/bin/bash
# Description: Verify nullability of commission fields in types and schema
# Check Rewardful type definitions
rg -nP --type=ts -A5 'interface.*RewardfulCampaign'
# Check if these fields are nullable
rg -nP --type=ts 'commission_amount_cents|commission_percent'
# Check Prisma schema for Reward.amount nullability
fd -e prisma -x cat {} | grep -A10 "model Reward"Length of output: 1824
Add runtime checks for commission values
Ensure commission_amount_cents and commission_percent are not null/undefined (Prisma amount is non-nullable) by adding validation or defaulting to 0 before prisma.reward.create.
| campaignIds: z.array(z.string()), | ||
| action: rewardfulImportSteps, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Keep server-side invariant: require at least one campaign ID
The old schema guaranteed a campaign was provided (single campaignId). After switching to campaignIds, the schema now accepts empty arrays. That lets clients queue an import with zero campaigns, which is a regression in server-side validation even if the UI blocks it. Please add .min(1, "Select at least one campaign") so the action continues to reject empty selections.
🤖 Prompt for AI Agents
In apps/web/lib/rewardful/schemas.ts around lines 14 to 15, the campaignIds
array schema currently allows empty arrays which regresses the previous
server-side invariant that at least one campaign must be provided; update the
z.array(z.string()) definition to enforce a minimum length by adding .min(1,
"Select at least one campaign") so the schema rejects empty selections and
preserves server-side validation; ensure the error message matches the reviewer
text and keep the rest of the schema unchanged.
| return ( | ||
| <Command.Item | ||
| key={campaign.id} | ||
| value={campaign.name} | ||
| onSelect={() => | ||
| setSelectedCampaignIds( | ||
| selectedCampaignIds?.includes(campaign.id) | ||
| ? selectedCampaignIds.length === 1 | ||
| ? null // Revert to null if there will be no campaigns selected | ||
| : selectedCampaignIds.filter( | ||
| (id) => id !== campaign.id, | ||
| ) | ||
| : [ | ||
| ...(selectedCampaignIds ?? []), | ||
| campaign.id, | ||
| ], | ||
| ) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prevent deselect-all from reverting to “import everything”
When the last selected campaign is toggled off in “Select campaigns” mode, setSelectedCampaignIds sets the state back to null. handleCampaignsSubmit treats null as “import every campaign,” so submitting the form (e.g., by pressing Enter in the search input) queues every campaign even though the user explicitly deselected all. Please keep “no selection” distinct from “all campaigns.” One fix is to leave the state as an empty array when the last item is removed and only coerce to null when the user switches back to the “All campaigns” toggle.
🤖 Prompt for AI Agents
In apps/web/ui/modals/import-rewardful-modal.tsx around lines 426 to 443, the
current toggle logic sets selectedCampaignIds to null when the last selected
campaign is removed which is later interpreted as “import all”; change the
behavior so removing the last campaign leaves selectedCampaignIds as an empty
array ([]) rather than null, and only set selectedCampaignIds to null when the
user explicitly switches the UI to the “All campaigns” mode; update the onSelect
branch to return [] for the case where selectedCampaignIds.length === 1 (instead
of null) and ensure any mode switch handler coerces [] → null only when the user
chooses “All campaigns.”
Summary by CodeRabbit
New Features
Refactor