-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Account for partner groups in program creation #2753
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
|
Warning Rate limit exceeded@steven-tey has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 9 minutes and 24 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (5)
WalkthroughRemoved program-level GET endpoints for rewards/discounts, eliminated Reward/Discount Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Server
participant Groups as Partner Groups
participant DB as Prisma
Client->>Server: createAndEnrollPartner({ programId, groupId? })
Server->>Groups: getGroupOrThrow(groupId || program.defaultGroupId) (include rewards/discount)
Groups-->>Server: { id, saleRewardId, leadRewardId, clickRewardId, discountId }
Server->>DB: upsert programEnrollment with group-derived IDs (groupId, sale/lead/clickRewardId, discountId)
DB-->>Server: Enrollment result
Server-->>Client: Enrollment response
sequenceDiagram
participant Client
participant Server
participant Storage as File Storage
participant DB as Prisma
Client->>Server: createProgram({ logo?, rewards })
Server->>Storage: uploadLogo(logo)
Storage-->>Server: logoUrl
Server->>DB: Transaction: create program { logo: logoUrl }, create rewards, upsert partner group mapping (map first reward to group column)
DB-->>Server: Program + Reward + Group
Server-->>Client: Program created
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
✨ Finishing Touches
🧪 Generate unit tests
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. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
🔭 Outside diff range comments (2)
apps/web/scripts/migrations/backfill-partner-groups.ts (2)
151-158: Default group selection can silently fail depending on iteration order.
isDefaultGrouprelies onprogramIdCounts[group.programId] === 1(i.e., “the first group we encounter for a program”). Sincegroupsare ordered by_count.programId: "desc"(size of each group) across all programs, the first group for a program may not be the one containing the default reward. If a non-default-reward group is processed first, the actual default group for that program will never be marked as default.Pick the default group per program based on the presence of the default reward, independent of iteration order. A minimal change:
- Track whether a program has already been assigned a default group.
- Set
isDefaultGroupto true when encountering the first group for that program that contains a default reward (not the first group overall).Apply within this block:
- if (programIdCounts[group.programId] === undefined) { - programIdCounts[group.programId] = 0; - } - programIdCounts[group.programId]++; - - const isDefaultGroup = - hasDefaultReward && programIdCounts[group.programId] === 1; + if (programIdCounts[group.programId] === undefined) { + programIdCounts[group.programId] = 0; + } + programIdCounts[group.programId]++; + + // Track assignment separately from count-based ordering + if (typeof defaultGroupAssigned === "undefined") { + // add near other counters; see snippet below + } + const isDefaultGroup = hasDefaultReward && !defaultGroupAssigned[group.programId]; + if (isDefaultGroup) { + defaultGroupAssigned[group.programId] = true; + }Add this near the other counters (around Line 77–80):
// Tracks if we've already assigned a default group for a program const defaultGroupAssigned: Record<string, boolean> = {};This ensures the first group containing a default reward becomes the program’s default, regardless of iteration order.
I can push a follow-up patch with the full change if useful.
188-196: Defer Program.defaultGroupId updates until after creating PartnerGroup recordsThe migration currently updates each Program’s defaultGroupId inside the loop (around lines 188–196) before any PartnerGroup rows are actually inserted (via
prisma.partnerGroup.createManyat lines 250–254). IfProgram.defaultGroupIdhas a FK →PartnerGroup.id, those updates will fail.Please refactor as follows:
• In apps/web/scripts/migrations/backfill-partner-groups.ts, replace the in‐loop update with a collector:
- if (isDefaultGroup) { - await prisma.program.update({ - where: { id: group.programId }, - data: { defaultGroupId: finalGroupId }, - }); - console.log( - `Updated program ${group.programId} with default group ${finalGroupId}`, - ); - } + if (isDefaultGroup) { + defaultGroupUpdates.push({ + programId: group.programId, + groupId: finalGroupId, + }); + }• Near the top of the file, declare the collector:
const defaultGroupUpdates: Array<{ programId: string; groupId: string }> = [];• After the
prisma.partnerGroup.createMany(…)call (lines 250–254), add:for (const { programId, groupId } of defaultGroupUpdates) { await prisma.program.update({ where: { id: programId }, data: { defaultGroupId: groupId }, }); console.log(`Updated program ${programId} with default group ${groupId}`); }This ensures all PartnerGroups exist before setting any FK, avoiding constraint errors.
🧹 Nitpick comments (20)
apps/web/scripts/migrations/migrate-program-invites.ts (2)
61-71: Unused variable from link update.
linkResis never used after the update/record. Consider dropping the binding or prefixing with_to avoid lints and signal intent.Apply:
- const linkRes = await prisma.link + await prisma.link .update({ where: { id: link.id, }, data: { programId: programInvite.programId, partnerId: partner.id, }, }) .then((link) => recordLink(link));
10-11: Unify import paths in migrate-program-invites.tsBoth
getEventsandrecordLinkexports were found at:
apps/web/lib/analytics/get-events.tsapps/web/lib/tinybird/record-link.tsYour current imports
import { getEvents } from "../../lib/analytics/get-events"; import { recordLink } from "../../lib/tinybird";correctly resolve those modules, but the file still mixes
@/lib/...aliases and../../lib/...relative imports. If you run this script via ts-node/tsx without enabling TS path-mapping, any remaining@/lib/...imports will fail at runtime.• Option A (recommended): convert all remaining
@/lib/...imports in this script to relative paths for consistency.
• Option B: ensure your script runner is configured to honortsconfig.json’spathsso aliases resolve correctly.[optional_refactors_recommended]
apps/web/scripts/migrations/backfill-partner-groups.ts (1)
142-149: Default-group detection is OK, but depends on legacy field; keep the ignore minimal.The ts-ignore on reward.default is reasonable for a one-off migration tied to a legacy schema. However, make sure this script is executed on a client built from the schema that still contains
Reward.default, or add a guard similar to the one suggested in migrate-rewards-remainder.ts to fail fast with a helpful message.Would you like me to add the same guard pattern here?
apps/web/lib/rewardful/schemas.ts (1)
14-18: Rename to groupId looks right; consider back-compat for in-flight jobsIf any queued/import payloads still send rewardId, this will 400 without a migration step. You can accept both and map rewardId -> groupId transparently, then remove rewardId later.
Apply this diff to keep the new contract while providing a safe transition:
export const rewardfulImportPayloadSchema = z.object({ importId: z.string(), userId: z.string(), programId: z.string(), - groupId: z.string().optional(), + // New + groupId: z.string().optional(), + // Back-compat (deprecated) + rewardId: z.string().optional(), campaignId: z.string(), action: rewardfulImportSteps, page: z.number().optional(), -}); +}).transform((data) => ({ + ...data, + // Prefer explicit groupId; fall back to deprecated rewardId if present + groupId: data.groupId ?? data.rewardId, +}));apps/web/lib/api/partners/create-and-enroll-partner.ts (3)
85-89: Guard against missing defaultGroupId; avoid non-null assertion and drop unnecessary includeThe non-null assertion on program.defaultGroupId can throw at runtime if a caller bypasses the earlier check. Also, you’re not using the included relations from getGroupOrThrow here.
Apply this diff to resolve the group ID explicitly and skip relation includes:
- const group = await getGroupOrThrow({ - programId: program.id, - groupId: groupId || program.defaultGroupId!, - includeRewardsAndDiscount: true, - }); + const resolvedGroupId = groupId ?? program.defaultGroupId; + if (!resolvedGroupId) { + throw new DubApiError({ + code: "bad_request", + message: + "Group ID is required but neither an explicit groupId nor program.defaultGroupId was provided.", + }); + } + const group = await getGroupOrThrow({ + programId: program.id, + groupId: resolvedGroupId, + });
48-64: Duplicate enrollment pre-checks can race; rely on unique constraints for true guaranteesThe pre-checks for existing enrollments by email can race under concurrent requests. Consider enforcing uniqueness at the DB layer (unique composite constraint) and catching conflicts, using the pre-checks only for UX.
I can propose a pattern that wraps the upsert/create in a try/catch for Prisma’s P2002 conflict if you want.
160-176: Avoid mixing booleans with Promises in Promise.allSettledThe
link.saleAmount > 0 && backfillLinkCommissions(...)expression adds a boolean when the condition is false. While harmless, it’s noisy and can confuse readers.Apply this diff for clarity:
- Promise.allSettled([ + Promise.allSettled([ linkCache.delete({ domain: link.domain, key: link.key, }), recordLink(link), - link.saleAmount > 0 && - backfillLinkCommissions({ - id: link.id, - partnerId: upsertedPartner.id, - programId: program.id, - }), + ...(link.saleAmount > 0 + ? [ + backfillLinkCommissions({ + id: link.id, + partnerId: upsertedPartner.id, + programId: program.id, + }), + ] + : []), ]),apps/web/lib/actions/partners/invite-partner.ts (2)
80-82: Make the error more actionableIncluding the programId helps debugging when multiple programs exist.
Apply this diff:
- if (!groupId && !program.defaultGroupId) { - throw new Error("No group ID provided and no default group ID found."); - } + if (!groupId && !program.defaultGroupId) { + throw new Error( + `No group ID provided and no default group ID found for program ${program.id}.`, + ); + }
84-95: Pass an explicit fallback groupId to the enroll callThis keeps createAndEnrollPartner free of non-null assertions and makes the intent here explicit.
Apply this diff:
const enrolledPartner = await createAndEnrollPartner({ program, link, workspace, partner: { name, email, }, skipEnrollmentCheck: true, status: "invited", - groupId, + groupId: groupId ?? program.defaultGroupId!, });apps/web/lib/api/programs/get-program-or-throw.ts (1)
23-28: Use a truly unique selector in findUnique (or switch to findFirst for scoped lookups)Passing both id and workspaceId to findUnique is atypical unless you have a composite unique on (id, workspaceId). Since id is globally unique, simplify the selector.
Apply this diff:
- const program = (await prisma.program.findUnique({ - where: { - id: programId, - workspaceId, - }, - })) as ProgramProps | null; + const program = (await prisma.program.findUnique({ + where: { id: programId }, + })) as ProgramProps | null;If you do need workspace scoping, prefer:
- findFirst({ where: { id: programId, workspaceId } })
- or a composite unique input (e.g., where: { id_workspaceId: { id: programId, workspaceId } }) if declared in Prisma.
apps/web/lib/actions/partners/create-program.ts (3)
87-92: Avoid external I/O inside the Prisma transactionUploading to R2 while holding a DB transaction increases lock time and risks orphaned files on rollback. Upload first, then start the transaction.
Apply this refactor:
- Move logo upload before the transaction:
// Before prisma.$transaction const programId = createId({ prefix: "prog_" }); const defaultGroupId = createId({ prefix: "grp_" }); const logoUrl = uploadedLogo ? await storage .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo) .then(({ url }) => url) : null;
- Inside the transaction, remove the upload block and use logoUrl:
- const programId = createId({ prefix: "prog_" }); - const defaultGroupId = createId({ prefix: "grp_" }); - - const logoUrl = uploadedLogo - ? await storage - .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo) - .then(({ url }) => url) - : null; + // programId, defaultGroupId, and logoUrl are precomputed outside the transaction
108-119: Be explicit about allowed zero values when gating reward creationUsing truthiness will skip creation when amount is 0; if 0 is invalid, keep as-is. If you want to gate on presence, check for nullish instead.
Apply this diff:
- ...(type && - amount && { + ...(type != null && + amount != null && { rewards: { create: { id: createId({ prefix: "rw_" }), type, amount, maxDuration, event: defaultRewardType, }, }, }),
271-281: Pass the default group explicitly when inviting partnersThis avoids relying on downstream fallbacks and makes the group assignment intention clear.
Apply this diff:
await createAndEnrollPartner({ program, link: partnerLink, workspace, partner: { name: partner.email.split("@")[0], email: partner.email, }, skipEnrollmentCheck: true, status: "invited", + groupId: program.defaultGroupId!, });apps/web/lib/rewardful/import-campaign.ts (3)
56-61: Redundanteventin thewhereclauseevent is already part of rewardProps; specifying it again is unnecessary.
Apply:
- const existingReward = await prisma.reward.findFirst({ - where: { ...rewardProps, event: EventType.sale }, + const existingReward = await prisma.reward.findFirst({ + where: { ...rewardProps },
63-97: Make reward + group creation atomic to avoid race conditionsIf multiple imports run concurrently, you can end up with duplicate rewards or group updates failing mid-way. Wrap the reward creation and group create/update in a single transaction and capture the final groupId deterministically.
Apply:
- if (!existingReward) { - // if no existing reward, create a new one + group - const createdReward = await prisma.reward.create({ - data: { - ...rewardProps, - id: createId({ prefix: "rw_" }), - }, - }); - - // if the default group has an associated sale reward already, we need to create a new group - if (groups[0].saleRewardId) { - const createdGroup = await prisma.partnerGroup.create({ - data: { - id: createId({ prefix: "grp_" }), - programId, - name: `(Rewardful) ${campaign.name}`, - slug: `rewardful-${campaignId}`, - color: randomValue(RESOURCE_COLORS), - saleRewardId: createdReward.id, - }, - }); - groupId = createdGroup.id; - // else we just update the existing group with the newly created sale reward - } else { - const updatedGroup = await prisma.partnerGroup.update({ - where: { - id: groups[0].id, - }, - data: { - saleRewardId: createdReward.id, - }, - }); - groupId = updatedGroup.id; - } - } else { + if (!existingReward) { + // Create reward and link/create group atomically + const [, finalGroup] = await prisma.$transaction(async (tx) => { + const createdReward = await tx.reward.create({ + data: { + ...rewardProps, + id: createId({ prefix: "rw_" }), + }, + }); + + if (groups[0].saleRewardId) { + const createdGroup = await tx.partnerGroup.create({ + data: { + id: createId({ prefix: "grp_" }), + programId, + name: `(Rewardful) ${campaign.name}`, + slug: `rewardful-${campaignId}`, + color: randomValue(RESOURCE_COLORS), + saleRewardId: createdReward.id, + }, + }); + return [createdReward, createdGroup] as const; + } else { + const updatedGroup = await tx.partnerGroup.update({ + where: { id: groups[0].id }, + data: { saleRewardId: createdReward.id }, + }); + return [createdReward, updatedGroup] as const; + } + }); + groupId = finalGroup.id; + } else {
99-100: Avoid non-null assertion; add a safe fallback forgroupIdexistingReward may not be linked to a group. Fall back to the default group's id when present.
Apply:
- groupId = existingReward.salePartnerGroup?.id!; + groupId = existingReward.salePartnerGroup?.id ?? groups[0]?.id ?? null;apps/web/lib/partnerstack/import-partners.ts (1)
156-165: Consider syncing group attributes on update as wellUpsert.update only sets status. If an enrollment exists with stale or missing group/reward IDs, it won’t be corrected. If desired, also set defaultGroupAttributes in update (idempotent if unchanged).
Apply:
update: { - status: "approved", + status: "approved", + ...defaultGroupAttributes, },If you intentionally don’t want to mutate existing enrollments’ group assignment, ignore this.
apps/web/lib/rewardful/import-partners.ts (2)
75-92: Make partner creation resilient to individual failuresUsing Promise.all will reject the entire batch if one createPartnerAndLinks call fails. Consider Promise.allSettled (as used in Tolt importer) to continue importing others and log failures.
Apply:
- await Promise.all( - activeAffiliates.map((affiliate) => - createPartnerAndLinks({ - program, - affiliate, - userId, - defaultGroupAttributes: { - groupId: defaultGroup.id, - saleRewardId: defaultGroup.saleRewardId, - leadRewardId: defaultGroup.leadRewardId, - clickRewardId: defaultGroup.clickRewardId, - discountId: defaultGroup.discountId, - }, - }), - ), - ); + const results = await Promise.allSettled( + activeAffiliates.map((affiliate) => + createPartnerAndLinks({ + program, + affiliate, + userId, + defaultGroupAttributes: { + groupId: defaultGroup.id, + saleRewardId: defaultGroup.saleRewardId, + leadRewardId: defaultGroup.leadRewardId, + clickRewardId: defaultGroup.clickRewardId, + discountId: defaultGroup.discountId, + }, + }), + ), + ); + // Optional: log failures for observability + results.forEach((r, i) => { + if (r.status === "rejected") { + console.error("Rewardful partner import failed", { + email: activeAffiliates[i]?.email, + reason: r.reason, + }); + } + });
149-165: Consider updating enrollment group attributes on upsert.updateSame as PartnerStack flow: update currently only flips status. If you want existing enrollments to reflect the selected group, include defaultGroupAttributes on update as well.
Apply:
update: { - status: "approved", + status: "approved", + ...defaultGroupAttributes, },If changing an existing partner’s group is risky for your product semantics, keep the current behavior.
apps/web/lib/tolt/import-partners.ts (1)
151-167: Optionally sync group attributes on enrollment updateAs with the other importers, consider including defaultGroupAttributes in the update path if you want existing enrollments to align with the default group.
Apply:
update: { - status: "approved", + status: "approved", + ...defaultGroupAttributes, },
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (17)
apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts(0 hunks)apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts(0 hunks)apps/web/app/(ee)/api/programs/[programId]/route.ts(0 hunks)apps/web/lib/actions/partners/create-program.ts(5 hunks)apps/web/lib/actions/partners/invite-partner.ts(1 hunks)apps/web/lib/api/partners/create-and-enroll-partner.ts(4 hunks)apps/web/lib/api/programs/get-program-or-throw.ts(1 hunks)apps/web/lib/partnerstack/import-partners.ts(5 hunks)apps/web/lib/rewardful/import-campaign.ts(3 hunks)apps/web/lib/rewardful/import-partners.ts(6 hunks)apps/web/lib/rewardful/schemas.ts(1 hunks)apps/web/lib/tolt/import-partners.ts(5 hunks)apps/web/scripts/migrations/backfill-partner-groups.ts(1 hunks)apps/web/scripts/migrations/migrate-program-invites.ts(1 hunks)apps/web/scripts/migrations/migrate-rewards-remainder.ts(1 hunks)packages/prisma/schema/discount.prisma(0 hunks)packages/prisma/schema/reward.prisma(0 hunks)
💤 Files with no reviewable changes (5)
- packages/prisma/schema/discount.prisma
- packages/prisma/schema/reward.prisma
- apps/web/app/(ee)/api/programs/[programId]/route.ts
- apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts
- apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/scripts/migrations/backfill-partner-groups.tsapps/web/lib/rewardful/import-campaign.tsapps/web/lib/rewardful/import-partners.tsapps/web/lib/api/partners/create-and-enroll-partner.tsapps/web/lib/actions/partners/create-program.tsapps/web/lib/partnerstack/import-partners.tsapps/web/lib/tolt/import-partners.ts
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/scripts/migrations/backfill-partner-groups.ts
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
Applied to files:
apps/web/lib/partnerstack/import-partners.tsapps/web/lib/tolt/import-partners.ts
🧬 Code Graph Analysis (6)
apps/web/lib/rewardful/import-campaign.ts (5)
apps/web/lib/rewardful/types.ts (1)
RewardfulImportPayload(102-104)apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/api/create-id.ts (1)
createId(57-66)apps/web/ui/colors.ts (1)
RESOURCE_COLORS(36-38)apps/web/lib/rewardful/importer.ts (1)
rewardfulImporter(45-45)
apps/web/lib/rewardful/import-partners.ts (1)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
getGroupOrThrow(4-51)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/zod/schemas/rewards.ts (1)
REWARD_EVENT_COLUMN_MAPPING(118-122)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/partnerstack/types.ts (1)
PartnerStackPartner(21-21)
apps/web/lib/tolt/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/tolt/types.ts (1)
ToltAffiliate(25-25)
⏰ 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 (11)
apps/web/scripts/migrations/backfill-partner-groups.ts (1)
81-116: Duplication strategy for reward/discount IDs looks solid.The counting-and-duplication logic to enforce the one-to-one relationship between rewards/discounts and groups matches the partner-groups constraint we’ve seen in prior work. Deep-cloning
modifiersavoids JSON reference pitfalls.apps/web/lib/actions/partners/create-program.ts (1)
141-144: Dynamic reward-to-group mapping is spot-onUsing REWARD_EVENT_COLUMN_MAPPING to set the correct reward column keeps group defaults consistent with the created reward.
apps/web/lib/rewardful/import-campaign.ts (3)
14-25: Default group lookup is correct and safely scopedFiltering groups by DEFAULT_PARTNER_GROUP.slug and relying on groups[0] aligns with the invariant that a default group always exists.
42-52: Looking at theRewardmodel:
maxDuration Int? // in months (0 -> not recurring, null -> infinite)amount Intwith no unit comment, but downstream code treats it as:
• cents when type=flat
• percent (0–100) when type=percentageIn
import-campaign.ts, you map:maxDuration: max_commission_period_months, amount: reward_type === "amount" ? commission_amount_cents : commission_percent,
max_commission_period_monthsis already in months → correct formaxDuration.commission_amount_centsis cents → correct for flatamount.commission_percentis a whole‐number percent → correct for percentageamount.No unit mismatch detected. You can safely remove this check.
102-110: Review importCampaign’s program update guardUnconditionally setting
minPayoutAmountandholdingPeriodDayshere will overwrite any custom program settings on every import. These fields propagate into email templates, UI badges, payout-processing cron jobs, API routes, schemas, and more—any unintended override could break payout thresholds or holding-period logic across the app.Please confirm intended behavior:
- Should
importCampaignalways overwrite existing values, or only initialize them when they’re still at their default (0)?- If initializing only once is desired, wrap the update in a conditional (e.g., check current program values before setting).
Location to adjust:
apps/web/lib/rewardful/import-campaign.ts, lines 104–110 (prisma.program.updatecall)apps/web/lib/partnerstack/import-partners.ts (2)
24-33: Default group selection pattern looks goodFiltering by DEFAULT_PARTNER_GROUP.slug and using program.groups[0] is consistent with the invariant that the default group is always present.
62-69: Good move to centralize on group-derived enrollment attributesPassing defaultGroupAttributes keeps enrollment consistent with group ownership. This removes event-specific branching and simplifies the flow.
apps/web/lib/rewardful/import-partners.ts (2)
36-37: EnsuredefaultGroupis presentWith the OR fallback above, defaultGroup will always be defined. Otherwise, this line can throw at runtime.
If you keep the current where, add an explicit check to throw a helpful error when no group is found.
111-116: Conditional propagation of groupId to next page is correctOnly forwarding groupId during partner pagination is consistent with the group-based enrollment flow.
apps/web/lib/tolt/import-partners.ts (2)
18-27: Default group selection aligns with new group-centric modelQuerying by DEFAULT_PARTNER_GROUP.slug and using program.groups[0] keeps the importer consistent with group ownership.
63-79: Nice use ofPromise.allSettledwith result extractionCollecting only fulfilled partners ensures downstream addPartners only processes valid ids.
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
|
@coderabbitai full review please |
|
@steven-tey I'll perform a full review of all the changes in this PR. ✅ 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: 3
🔭 Outside diff range comments (2)
apps/web/scripts/migrations/backfill-partner-groups.ts (1)
144-149: Harden default‐reward detection after schema changeThe
Rewardmodel inpackages/prisma/schema/reward.prismano longer includes adefaultfield, so:
- The
// @ts-ignorehides a real type error.- At runtime
reward.defaultis alwaysundefined⇒
hasDefaultRewardis never true ⇒
no default partner group ever gets set.Please update the migration to:
- Remove the
// @ts-ignoreand use a safe check:
Boolean((reward as any)?.default)- Detect whether any reward has the flag, and if not, fall back to marking the first group per program as default.
- Ensure you still call
prisma.program.update({ data: { defaultGroupId: … } })whenisDefaultGroupis true.Suggested diff around lines 144–149 of
apps/web/scripts/migrations/backfill-partner-groups.ts:- const hasDefaultReward = rewards.some( - (reward) => - // @ts-ignore (old reward schema) - reward.default && - (reward.id === group.saleRewardId || - reward.id === group.leadRewardId || - reward.id === group.clickRewardId), - ); + // Safely detect a default flag (if it still exists) + const hasDefaultReward = rewards.some((reward) => + Boolean((reward as any)?.default) && + (reward.id === group.saleRewardId || + reward.id === group.leadRewardId || + reward.id === group.clickRewardId), + ); + + // If no reward has a default flag, fall back to the first group per program + const defaultFlagExists = rewards.some((r) => Boolean((r as any)?.default)); + const isDefaultGroup = + programIdCounts[group.programId] === 1 && + (defaultFlagExists ? hasDefaultReward : true);This ensures a default partner group is always assigned, even after dropping the
Reward.defaultcolumn.apps/web/lib/actions/partners/create-program.ts (1)
211-215: Finalize logo after commit: upload to program path, update DB, then delete tempPerform the upload and DB update after the transaction succeeds, and only then delete the temporary upload. This makes the workflow idempotent and removes the rollback/leak risk.
- // delete the temporary uploaded logo - uploadedLogo && - isStored(uploadedLogo) && - storage.delete(uploadedLogo.replace(`${R2_URL}/`, "")), + // finalize the logo post-commit: upload to program path, set DB field, then delete temp + uploadedLogo && + isStored(uploadedLogo) && + (async () => { + const { url } = await storage.upload( + `programs/${program.id}/logo_${nanoid(7)}`, + uploadedLogo, + ); + await prisma.program.update({ + where: { id: program.id }, + data: { logo: url }, + }); + await storage.delete(uploadedLogo.replace(`${R2_URL}/`, "")); + })(),
♻️ Duplicate comments (4)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)
1-2: ts-nocheck hides Prisma client mismatch; add runtime guard or document orderingThis file still queries
Reward.default(Line 37 viawhere: { default: true }). With// @ts-nocheck, TypeScript won’t warn, but Prisma will throw at runtime once the field is removed.
- Either document that this script must run before removing
Reward.defaultand with a client generated from the legacy schema, or- Add a small runtime guard that catches “Unknown arg
default” and exits with a clear message.Apply this minimal diff to avoid silencing the compiler entirely:
-// @ts-nocheck +Then wrap the default-reward lookup:
let programDefaultRewards; try { programDefaultRewards = await prisma.reward.findMany({ where: { programId: { in: programEnrollmentsWithNoReward.map((e) => e.programId), }, // @ts-expect-error: legacy schema default: true, }, }); } catch (err: any) { if (`${err?.message || err}`.includes("Unknown arg `default`")) { console.error( "This script must run before removing Reward.default and with a Prisma client generated from the legacy schema.", ); process.exit(1); } throw err; }apps/web/lib/rewardful/import-campaign.ts (1)
72-85: Make PartnerGroup creation idempotent (slug is unique per program)Creating with
slug: rewardful-${campaignId}will throw on repeat imports due to the composite unique constraint on [programId, slug]. Upsert to avoid collisions.- const createdGroup = await prisma.partnerGroup.create({ - data: { - id: createId({ prefix: "grp_" }), - programId, - name: `(Rewardful) ${campaign.name}`, - slug: `rewardful-${campaignId}`, - color: randomValue(RESOURCE_COLORS), - saleRewardId: createdReward.id, - }, - }); - groupId = createdGroup.id; + const slug = `rewardful-${campaignId}`; + const group = await prisma.partnerGroup.upsert({ + where: { programId_slug: { programId, slug } }, + create: { + id: createId({ prefix: "grp_" }), + programId, + name: `(Rewardful) ${campaign.name}`, + slug, + color: randomValue(RESOURCE_COLORS), + saleRewardId: createdReward.id, + }, + update: { + saleRewardId: createdReward.id, + }, + }); + groupId = group.id;apps/web/lib/api/programs/get-program-or-throw.ts (1)
23-29: Include landerData when requested; current query shape can’t satisfy ProgramWithLanderDataSchemaWhen includeLanderData is true, the findUnique call doesn’t include the related landerData, so parsing with ProgramWithLanderDataSchema will likely fail or drop fields. Add a conditional include.
Apply:
- const program = (await prisma.program.findUnique({ - where: { - id: programId, - workspaceId, - }, - })) as ProgramProps | null; + const program = await prisma.program.findUnique({ + where: { + id: programId, + workspaceId, + }, + include: includeLanderData + ? { + // pull only what ProgramWithLanderDataSchema actually expects + // refine these relations if the schema requires nested fields + landerData: true, + } + : undefined, + });Optional type-safety improvement: instead of casting to ProgramProps, use Prisma’s inferred payload types (e.g., Prisma.ProgramGetPayload) so the compiler helps keep the parse schema and query in sync.
Run this to confirm exactly which relations ProgramWithLanderDataSchema requires:
#!/bin/bash # Inspect ProgramWithLanderDataSchema and lander schema usage rg -n -C4 'export\s+const\s+ProgramWithLanderDataSchema' apps/web/lib/zod/schemas rg -n -C4 'programLanderSchema|landerData' apps/web/lib/zod/schemasapps/web/lib/rewardful/import-partners.ts (1)
26-33: Bug: invalid groupId yields empty groups[] and runtime crash; add OR fallback to default groupAs written, an invalid groupId filters out all groups, making program.groups[0] undefined and crashing when accessed later. Prefer an OR filter that falls back to the default group when the provided id is invalid.
Apply:
- include: { - groups: { - // if groupId is provided, use it, otherwise use the default group - where: { - ...(groupId ? { id: groupId } : { slug: DEFAULT_PARTNER_GROUP.slug }), - }, - }, - }, + include: { + groups: { + // prefer provided groupId; gracefully fallback to the default group + where: { + OR: groupId + ? [{ id: groupId }, { slug: DEFAULT_PARTNER_GROUP.slug }] + : [{ slug: DEFAULT_PARTNER_GROUP.slug }], + }, + }, + },
🧹 Nitpick comments (13)
apps/web/scripts/migrations/migrate-program-invites.ts (1)
10-11: Verify mixed alias vs relative imports for script runtimeYou’re mixing "@/lib/…" path aliases with "../../lib/…" relative imports in the same script. Depending on how this script is executed (ts-node/tsx without tsconfig-paths), one of these resolution strategies might fail.
- If scripts are run with tsconfig paths enabled, prefer consistent "@/lib/…" aliases everywhere.
- If scripts are executed without path alias support, prefer consistently relative paths.
Please confirm the intended runtime and standardize accordingly to avoid brittle module resolution.
apps/web/lib/rewardful/import-campaign.ts (2)
56-61: Minor: redundant event filter
event: EventType.saleis already inrewardProps, so adding it again inwhereis redundant. Safe to drop for clarity.- const existingReward = await prisma.reward.findFirst({ - where: { ...rewardProps, event: EventType.sale }, + const existingReward = await prisma.reward.findFirst({ + where: { ...rewardProps },
99-100: Avoid non-null assertion on possibly missing relation
existingReward.salePartnerGroup?.id!asserts presence at compile time but can still beundefinedat runtime. Either ensure the relation exists or handle the absence by falling back to the default group/new group creation.- groupId = existingReward.salePartnerGroup?.id!; + groupId = existingReward.salePartnerGroup?.id ?? null; + // Optionally: if null, attach this reward to the default group or create a group.apps/web/lib/api/programs/get-program-or-throw.ts (1)
37-40: Parsing toggle is fine; ensure the fetched shape matches the chosen schemaThe conditional parse based on includeLanderData is clean. It will be correct once the query includes the necessary relations when includeLanderData is true.
If you adopt the Prisma payload typing suggested above, you can drop the explicit ProgramProps cast and let the schema guard the runtime shape.
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
85-89: Add an explicit guard when neither groupId nor program.defaultGroupId is presentgetGroupOrThrow currently relies on groupId || program.defaultGroupId!, which can be undefined at runtime in edge cases (legacy/migrated programs). Throw a clearer error before calling getGroupOrThrow, and avoid the non-null assertion.
Apply:
- const group = await getGroupOrThrow({ - programId: program.id, - groupId: groupId || program.defaultGroupId!, - includeRewardsAndDiscount: true, - }); + if (!groupId && !program.defaultGroupId) { + throw new DubApiError({ + code: "bad_request", + message: + "No groupId provided and the program has no defaultGroupId configured.", + }); + } + const resolvedGroupId = (groupId ?? program.defaultGroupId)!; + const group = await getGroupOrThrow({ + programId: program.id, + groupId: resolvedGroupId, + includeRewardsAndDiscount: true, + });If all programs are guaranteed to have a default group after migrations, confirm that the invariant holds for all tenants and environments (incl. imports). Otherwise, this guard will save an opaque “not found” error.
apps/web/lib/rewardful/import-partners.ts (1)
36-37: Defensive check: ensure a group was returned before accessing its fieldsEven with the OR fallback, add a small guard to clarify errors if no default group exists (e.g., misconfigured program).
- const defaultGroup = program.groups[0]; + const defaultGroup = program.groups[0]; + if (!defaultGroup) { + throw new Error( + "Default partner group not found. Ensure the default group exists or pass a valid groupId.", + ); + }apps/web/lib/partnerstack/import-partners.ts (1)
32-33: Optional: guard against missing default groupIf a program somehow lacks the default group (migration gap), this will be undefined and fail later. A small guard improves debuggability.
- const defaultGroup = program.groups[0]; + const defaultGroup = program.groups[0]; + if (!defaultGroup) { + throw new Error( + `Default partner group not found for program ${program.id}.`, + ); + }apps/web/lib/actions/partners/create-program.ts (3)
84-86: Generate IDs outside the transaction for retry idempotencyIf Prisma retries the interactive transaction, generating new IDs on each attempt can lead to unexpected conflicts or duplicated side effects. Prefer generating programId/defaultGroupId before entering $transaction and reusing them inside.
- const programId = createId({ prefix: "prog_" }); - const defaultGroupId = createId({ prefix: "grp_" }); + // programId and defaultGroupId are generated outside $transaction for idempotencyOutside the transaction (just before calling prisma.$transaction), add:
const programId = createId({ prefix: "prog_" }); const defaultGroupId = createId({ prefix: "grp_" });
141-144: Guard the computed reward column to avoid sending an “undefined” field to PrismaIf the mapping is ever extended or event is unexpected,
[REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]could evaluate toundefined, yielding an object with an “undefined” key that Prisma will reject. Add a second guard for the mapping’s existence.- ...(createdReward && { - [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]: createdReward.id, - }), + ...(createdReward && + REWARD_EVENT_COLUMN_MAPPING[createdReward.event] && { + [REWARD_EVENT_COLUMN_MAPPING[createdReward.event]]: createdReward.id, + }),
217-224: Verify ProgramWelcome template behavior when logo is set post-commitWith the logo now finalized after commit,
program.logomay be null at email-send time. Confirm ProgramWelcome tolerates a missing logo or use a fallback for this email render.Optionally pass a fallback just for the email:
react: ProgramWelcome({ email: user.email!, workspace, program: { ...program, // Prefer the stored URL, but fall back to the temporary uploadedLogo if present logo: program.logo ?? (isStored(uploadedLogo) ? uploadedLogo : null), }, }),apps/web/lib/tolt/import-partners.ts (3)
18-27: Add a defensive check when resolving the default groupYou filter by slug and then read
program.groups[0]. Based on past learnings, the default group should always exist, but adding a small guard prevents a hard crash if assumptions change or data is corrupted.- const defaultGroup = program.groups[0]; + const defaultGroup = program.groups[0]; + if (!defaultGroup) { + throw new Error(`Default partner group not found for program ${program.id}`); + }
80-85: Naming nit: clarify settled results vs. promise
partnersPromiseholds settled results, not a promise. Renaming improves readability.- const partnersPromise = await Promise.allSettled( + const partnerResults = await Promise.allSettled( activeAffiliates.map((affiliate) => createPartner({ program, affiliate, defaultGroupAttributes: { groupId: defaultGroup.id, saleRewardId: defaultGroup.saleRewardId, leadRewardId: defaultGroup.leadRewardId, clickRewardId: defaultGroup.clickRewardId, discountId: defaultGroup.discountId, }, }), ), ); - const partners = partnersPromise + const partners = partnerResults .filter( (p): p is PromiseFulfilledResult<Partner> => p.status === "fulfilled", ) .map((p) => p.value);
151-167: Consider updating group attributes on existing enrollmentsOn upsert, you only update
status. If the default group’s reward IDs changed since a prior import, existing enrollments won’t reflect the new defaults. If that’s acceptable, ignore; otherwise, spread...defaultGroupAttributesinto the update payload.update: { status: "approved", + ...defaultGroupAttributes, },
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (17)
apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts(0 hunks)apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts(0 hunks)apps/web/app/(ee)/api/programs/[programId]/route.ts(0 hunks)apps/web/lib/actions/partners/create-program.ts(5 hunks)apps/web/lib/actions/partners/invite-partner.ts(1 hunks)apps/web/lib/api/partners/create-and-enroll-partner.ts(4 hunks)apps/web/lib/api/programs/get-program-or-throw.ts(1 hunks)apps/web/lib/partnerstack/import-partners.ts(5 hunks)apps/web/lib/rewardful/import-campaign.ts(3 hunks)apps/web/lib/rewardful/import-partners.ts(6 hunks)apps/web/lib/rewardful/schemas.ts(1 hunks)apps/web/lib/tolt/import-partners.ts(5 hunks)apps/web/scripts/migrations/backfill-partner-groups.ts(1 hunks)apps/web/scripts/migrations/migrate-program-invites.ts(1 hunks)apps/web/scripts/migrations/migrate-rewards-remainder.ts(1 hunks)packages/prisma/schema/discount.prisma(0 hunks)packages/prisma/schema/reward.prisma(0 hunks)
💤 Files with no reviewable changes (5)
- packages/prisma/schema/discount.prisma
- apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts
- apps/web/app/(ee)/api/programs/[programId]/route.ts
- apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts
- packages/prisma/schema/reward.prisma
🧰 Additional context used
🧠 Learnings (3)
📓 Common learnings
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/scripts/migrations/backfill-partner-groups.tsapps/web/lib/rewardful/import-partners.tsapps/web/lib/rewardful/import-campaign.tsapps/web/lib/api/partners/create-and-enroll-partner.tsapps/web/lib/partnerstack/import-partners.tsapps/web/lib/tolt/import-partners.tsapps/web/lib/actions/partners/create-program.ts
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
Applied to files:
apps/web/lib/partnerstack/import-partners.tsapps/web/lib/tolt/import-partners.ts
🧬 Code Graph Analysis (6)
apps/web/lib/rewardful/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/rewardful/types.ts (1)
RewardfulAffiliate(42-55)
apps/web/lib/rewardful/import-campaign.ts (5)
apps/web/lib/rewardful/types.ts (1)
RewardfulImportPayload(102-104)apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/api/create-id.ts (1)
createId(57-66)apps/web/ui/colors.ts (1)
RESOURCE_COLORS(36-38)apps/web/lib/rewardful/importer.ts (1)
rewardfulImporter(45-45)
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
getGroupOrThrow(4-51)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/partnerstack/types.ts (1)
PartnerStackPartner(21-21)
apps/web/lib/tolt/import-partners.ts (1)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)
apps/web/lib/actions/partners/create-program.ts (1)
apps/web/lib/zod/schemas/rewards.ts (1)
REWARD_EVENT_COLUMN_MAPPING(118-122)
🔇 Additional comments (8)
apps/web/lib/rewardful/schemas.ts (1)
14-14: Ignore the suggestion—rewardfulImportPayloadSchemaalready usesgroupIdand no longer referencesrewardId.
AllrewardIdhits from your search belong to the Reward entity (UI, migrations, Zod schemas, API actions) and are unrelated to the Rewardful import payload. The Rewardful importer’s schema, types, route, and queue calls consistently usegroupId(and neverrewardId), so no backward-compatible alias is required.Likely an incorrect or invalid review comment.
apps/web/lib/rewardful/import-campaign.ts (2)
102-111: LGTM: program payout and holding period synced from campaignUpdating
minPayoutAmountandholdingPeriodDaysunconditionally here keeps program-level settings aligned with Rewardful. No issues spotted.
14-25: Guard against missing default group (groups[0] may be undefined)You dereference
groups[0]later; if a program somehow lacks a default group, this will throw. Add a guard and, optionally, create the default group on-the-fly.You can also narrow the included fields and fetch just one group:
- const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({ + const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { groups: { where: { slug: DEFAULT_PARTNER_GROUP.slug, }, + select: { id: true, saleRewardId: true }, + take: 1, }, }, });Follow-up (outside this hunk):
const defaultGroup = groups[0]; if (!defaultGroup) { // Either throw with a clear message or create the default group here. throw new Error(`Default partner group missing for program ${programId}`); }⛔ Skipped due to learnings
Learnt from: devkiran PR: dubinc/dub#2735 File: apps/web/app/api/og/program/route.tsx:63-64 Timestamp: 2025-08-14T05:00:23.180Z Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.apps/web/lib/actions/partners/invite-partner.ts (1)
94-95: Pass-through groupId is good; fallback now centralized downstreamForwarding groupId as-provided (possibly undefined) keeps this action thin and delegates fallback to createAndEnrollPartner. You already guard earlier (Lines 80–82) to ensure at least one of groupId or program.defaultGroupId exists.
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
102-107: Group-centric enrollment payload looks correctUsing groupId plus reward/discount IDs sourced from the group aligns with the new model and eliminates the old per-event mapping complexity.
apps/web/lib/rewardful/import-partners.ts (1)
82-89: Propagation of group-derived attributes is aligned with the new modelPassing groupId and reward/discount IDs via defaultGroupAttributes keeps enrollment consistent and simplifies downstream logic.
apps/web/lib/partnerstack/import-partners.ts (2)
24-29: Good: default group filtered at the queryFetching only the default group and deriving attributes from it matches the new group-centric approach.
62-69: Propagation of group-derived attributes is consistentdefaultGroupAttributes structure and its downstream use in createPartner are consistent with the group-based enrollment flow.
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: 1
🔭 Outside diff range comments (6)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)
30-39: Prisma query filters on removedReward.defaultwill throw; add a runtime guard and narrow the TS ignoreWithout a guard, Prisma will error with “Unknown arg
defaultin where.default” when the generated client no longer has that field. Fail fast with a clear message and scope the TS ignore narrowly:- const programDefaultRewards = await prisma.reward.findMany({ - where: { - programId: { - in: programEnrollmentsWithNoReward.map( - (enrollment) => enrollment.programId, - ), - }, - default: true, - }, - }); + let programDefaultRewards; + try { + programDefaultRewards = await prisma.reward.findMany({ + where: { + programId: { + in: programEnrollmentsWithNoReward.map( + (enrollment) => enrollment.programId, + ), + }, + // @ts-expect-error - legacy schema only: Reward.default was dropped in newer versions + default: true, + }, + }); + } catch (err) { + const msg = String((err && (err as any).message) || err); + if (msg.includes("Unknown arg `default`") || msg.includes("Unknown argument `default`")) { + console.error( + "migrate-rewards-remainder.ts: Prisma client does not support Reward.default.\n" + + "Run this script BEFORE removing Reward.default and with a client generated from the legacy schema.", + ); + process.exit(1); + } + throw err; + }apps/web/scripts/migrations/backfill-partner-groups.ts (5)
28-33: Default group detection depends on iteration order; many programs may miss defaultGroupId
isDefaultGroupis gated byprogramIdCounts[programId] === 1. BecausegroupBy(...).orderBy({ _count: { programId: "desc" } })doesn’t guarantee the default group appears first within each program, a program may never have its default assigned even if a default reward exists.
- Track whether a default was assigned per program (map), not position.
- Set default when encountering the first group that contains a legacy-default reward, regardless of iteration order.
See diffs on Lines 70-76 (add map), 156-158 (change the condition), and 188-196 (set the flag when scheduling the update).
Also applies to: 151-158
188-196: Possible FK violation: updating Program.defaultGroupId before PartnerGroup rows exist
prisma.program.update({ defaultGroupId: finalGroupId })runs beforepartnerGroup.createMany. If there’s a foreign key from Program.defaultGroupId to PartnerGroup.id, this will fail. Also, doing program updates inside the Promise.all loop adds unnecessary DB churn.Defer these updates until after groups are created. Collect them during the map, then apply in a batch:
- if (isDefaultGroup) { - await prisma.program.update({ - where: { id: group.programId }, - data: { defaultGroupId: finalGroupId }, - }); - console.log( - `Updated program ${group.programId} with default group ${finalGroupId}`, - ); - } + if (isDefaultGroup) { + programDefaultGroupUpdates.push({ + programId: group.programId, + defaultGroupId: finalGroupId, + }); + programDefaultAssigned[group.programId] = true; + }And right after creating partner groups, apply the program updates:
console.log(`Created ${groupsRes.count} partner groups`); + // Apply default group updates after groups are created to avoid FK violations + if (programDefaultGroupUpdates.length) { + await prisma.$transaction( + programDefaultGroupUpdates.map(({ programId, defaultGroupId }) => + prisma.program.update({ + where: { id: programId }, + data: { defaultGroupId }, + }), + ), + ); + console.log( + `Updated default group for ${programDefaultGroupUpdates.length} programs`, + ); + } + for (const pe of programEnrollmentsToUpdate) {Also applies to: 250-256
70-76: Track default assignment per program and defer updates for batch executionIntroduce a per-program flag to avoid order-dependence and an array to batch program updates after group creation.
const programEnrollmentsToUpdate: { ids: string[]; data: Prisma.ProgramEnrollmentUpdateManyArgs["data"]; }[] = []; + // Track default-group assignment and defer program updates until groups are created + const programDefaultGroupUpdates: { programId: string; defaultGroupId: string }[] = []; + const programDefaultAssigned: Record<string, boolean> = {};
156-158: Make default-group assignment independent of iteration orderReplace the “first group per program” gate with a per-program flag.
- const isDefaultGroup = - hasDefaultReward && programIdCounts[group.programId] === 1; + const isDefaultGroup = + hasDefaultReward && !programDefaultAssigned[group.programId];
238-241: Verify unique constraints on createMany operationsOur schema inspection shows:
Reward model (packages/prisma/schema/reward.prisma)
• Only@idonidand two@@indexdefinitions—no non-ID unique constraints.
→reward.createMany({ skipDuplicates: true })will only skip onidconflicts (which you’re generating anew), so it’s safe.Discount model (packages/prisma/schema/discount.prisma)
• Only@idonidand one@@indexonprogramId—no non-ID unique constraints.
→discount.createMany({ skipDuplicates: true })is safe.PartnerGroup model (packages/prisma/schema/partner.prisma)
• Non-ID unique fields within the model block:
–partnerUserId String @unique
–clickRewardId String? @unique
–leadRewardId String? @unique
–saleRewardId String? @unique
–discountId String? @unique
–@@unique([programId, slug])
→ Any duplicate slug or reward/disc-ID fields will cause the insert to be skipped. Subsequent FK-based updates targeting those “new” IDs would then fail.Action items:
- For PartnerGroup backfill (lines 244-248, 250-256 in apps/web/scripts/migrations/backfill-partner-groups.ts):
• Ensure generatedslug(and any reward/disc-ID fields) are unique—e.g., suffix or normalize—beforecreateMany({ skipDuplicates: true }).
• Or switch to per-row upserts/updates for PartnerGroup to guarantee each record exists (avoiding silent skips).
♻️ Duplicate comments (7)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)
1-2: File-wide ts-nocheck hides a breaking Prisma schema mismatch; scope the ignore and add a runtime guard or clearly document orderingGiven this PR removes Reward.default across the schema, the query below using
where: { default: true }will throw at runtime even though// @ts-nochecksilences TS. Either:
- Keep this as a legacy-only script and document the ordering at the top, and add a runtime guard, or
- Remove the file-wide no-check, add a targeted
// @ts-expect-erroron thedefaultfilter, and add the same runtime guard.Minimal tweak to make the intent explicit without changing behavior:
-// @ts-nocheck - +// @ts-nocheck +// NOTE: This script expects a Prisma client generated from the legacy schema where Reward.default exists. +// It will fail fast with a clear message if run against the new schema (see guard around the `default` filter).To verify what Prisma schema is present in this branch and whether
Reward.defaultstill exists, run:#!/bin/bash set -euo pipefail echo "==> Reward model in Prisma schema (context):" rg -nP -C2 'model\s+Reward\b' packages/prisma || true echo echo "==> Occurrences of 'default' in Prisma schema directory (sanity check):" rg -nP -C2 '\bdefault\b' packages/prisma || trueapps/web/lib/rewardful/import-campaign.ts (2)
72-97: Guard against empty groups result and use defaultGroupId fallbackDereferencing groups[0] without a presence check can throw when there’s no “default” slug match. Use a fallback to the defaultGroupId and handle both cases.
- // if the default group has an associated sale reward already, we need to create a new group - if (groups[0].saleRewardId) { + // Resolve the default group via slug match first, then fall back to defaultGroupId + const defaultGroup = + groups[0] ?? + (defaultGroupId + ? await prisma.partnerGroup.findUnique({ where: { id: defaultGroupId } }) + : null); + + if (!defaultGroup) { + throw new Error("No default partner group found for this program."); + } + + // if the default group has an associated sale reward already, we need to create a new group + if (defaultGroup.saleRewardId) { const createdGroup = await prisma.partnerGroup.create({ data: { id: createId({ prefix: "grp_" }), programId, name: `(Rewardful) ${campaign.name}`, slug: `rewardful-${campaignId}`, color: randomValue(RESOURCE_COLORS), saleRewardId: createdReward.id, }, }); groupId = createdGroup.id; // else we just update the existing group with the newly created sale reward } else { const updatedGroup = await prisma.partnerGroup.update({ where: { - id: groups[0].id, + id: defaultGroup.id, }, data: { saleRewardId: createdReward.id, }, }); groupId = updatedGroup.id; }
74-84: Make group creation idempotent with upsert to avoid slug collisionsCreating a group with slug rewardful-${campaignId} can fail on re-runs due to the unique [programId, slug] constraint. Use upsert.
- const createdGroup = await prisma.partnerGroup.create({ - data: { - id: createId({ prefix: "grp_" }), - programId, - name: `(Rewardful) ${campaign.name}`, - slug: `rewardful-${campaignId}`, - color: randomValue(RESOURCE_COLORS), - saleRewardId: createdReward.id, - }, - }); - groupId = createdGroup.id; + const slug = `rewardful-${campaignId}`; + const group = await prisma.partnerGroup.upsert({ + where: { programId_slug: { programId, slug } }, + create: { + id: createId({ prefix: "grp_" }), + programId, + name: `(Rewardful) ${campaign.name}`, + slug, + color: randomValue(RESOURCE_COLORS), + saleRewardId: createdReward.id, + }, + update: { + saleRewardId: createdReward.id, + }, + }); + groupId = group.id;apps/web/lib/api/programs/get-program-or-throw.ts (1)
23-40: Include landerData when includeLanderData is true to satisfy schema parsingCurrently, you always fetch only base program fields. If ProgramWithLanderDataSchema expects related payloads, parse() may fail. Conditionally include landerData (and nested relations as required by the schema).
const program = (await prisma.program.findUnique({ where: { id: programId, workspaceId, }, - })) as ProgramProps | null; + include: includeLanderData + ? { + landerData: { + include: { + // Adjust these to match what ProgramWithLanderDataSchema parses + blocks: true, + rewards: true, + // add more nested relations if needed by your schema + }, + }, + } + : undefined, + })) as ProgramProps | null;If the schema expects a different shape, align the include accordingly.
apps/web/lib/rewardful/import-partners.ts (1)
26-33: Guard against invalid groupId: add OR fallback to default group and assert existenceIf an invalid/nonexistent groupId is provided, the current where returns zero groups, making program.groups[0] undefined and causing downstream crashes when accessing defaultGroup.*.
- Use an OR fallback to DEFAULT_PARTNER_GROUP.slug so a valid group is always returned.
- Add a defensive check after selection.
Apply:
- include: { - groups: { - // if groupId is provided, use it, otherwise use the default group - where: { - ...(groupId ? { id: groupId } : { slug: DEFAULT_PARTNER_GROUP.slug }), - }, - }, - }, + include: { + groups: { + // prefer provided groupId; gracefully fallback to the default group if missing/invalid + where: { + OR: groupId + ? [{ id: groupId }, { slug: DEFAULT_PARTNER_GROUP.slug }] + : [{ slug: DEFAULT_PARTNER_GROUP.slug }], + }, + }, + },And just after selecting the group:
- const defaultGroup = program.groups[0]; + const defaultGroup = program.groups[0]; + if (!defaultGroup) { + throw new Error(`Default partner group not found for program ${program.id}`); + }Also applies to: 36-37
apps/web/lib/actions/partners/create-program.ts (2)
87-92: Move storage.upload out of the Prisma $transactionUploading inside an interactive transaction risks orphaned blobs on rollback and duplicate uploads on retry. Perform the upload after the transaction commits, then update program.logo.
Apply in-transaction removal:
- const logoUrl = uploadedLogo - ? await storage - .upload(`programs/${programId}/logo_${nanoid(7)}`, uploadedLogo) - .then(({ url }) => url) - : null;Then, outside the transaction (example):
// after the transaction returns `program` if (uploadedLogo) { const { url } = await storage.upload( `programs/${program.id}/logo_${nanoid(7)}`, uploadedLogo, ); const updated = await prisma.program.update({ where: { id: program.id }, data: { logo: url }, }); // ensure subsequent emails/audit logs use the updated program with logo program = updated; }
99-100: Don’t set logo in program.create; assign post-commitOnce the upload is moved out of the transaction, drop this inline assignment to avoid referencing a non-existent logoUrl variable and to keep DB writes transactional.
- ...(logoUrl && { logo: logoUrl }), + // logo is set post-transaction to avoid orphaned uploads on rollback
🧹 Nitpick comments (22)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (2)
76-87: Avoid unbounded Promise.all on large datasets; consider batching or a concurrency limiterIf
updatedEnrollmentsis large, this will fan out an unbounded number of updates and can overload the DB. Two safer options:
- Sequential (simple and safe):
- await Promise.all( - updatedEnrollments.map((enrollment) => - prisma.programEnrollment.update({ - where: { id: enrollment.id }, - data: { - clickRewardId: enrollment.clickRewardId, - leadRewardId: enrollment.leadRewardId, - saleRewardId: enrollment.saleRewardId, - }, - }), - ), - ); + for (const enrollment of updatedEnrollments) { + await prisma.programEnrollment.update({ + where: { id: enrollment.id }, + data: { + clickRewardId: enrollment.clickRewardId, + leadRewardId: enrollment.leadRewardId, + saleRewardId: enrollment.saleRewardId, + }, + }); + }
- Or keep concurrency but cap it using p-limit (optional). I can provide a ready-to-drop snippet if you prefer this route.
90-91: Propagate failures and exit non‑zero on errorWrap
main()to ensure the script exits with code 1 on failure and logs the error:-main(); +main().catch((err) => { + console.error(err); + process.exit(1); +});apps/web/lib/rewardful/import-campaign.ts (2)
56-61: Redundant event filter in findFirstevent is already included in rewardProps as EventType.sale. Minor nit: drop the duplicate condition.
- const existingReward = await prisma.reward.findFirst({ - where: { ...rewardProps, event: EventType.sale }, + const existingReward = await prisma.reward.findFirst({ + where: { ...rewardProps }, include: { salePartnerGroup: true, // rewardful only supports sale rewards }, });
63-85: Consider wrapping reward+group mutations in a transactionCreating the reward, then updating/creating a group, and then updating the program are logically coupled. A failure mid-way could leave the reward orphaned. Optional, but a transaction would keep state consistent.
I can provide a transaction-wrapped version if you want to adopt it.
apps/web/lib/rewardful/import-partners.ts (2)
75-92: Don’t let a single partner failure abort the batch; use Promise.allSettledUsing Promise.all will reject on the first failure and stop remaining imports/logging. allSettled keeps the batch resilient and consistent with PartnerStack/Tolt importers.
- await Promise.all( + await Promise.allSettled( activeAffiliates.map((affiliate) => createPartnerAndLinks({ program, affiliate, userId, defaultGroupAttributes: { groupId: defaultGroup.id, saleRewardId: defaultGroup.saleRewardId, leadRewardId: defaultGroup.leadRewardId, clickRewardId: defaultGroup.clickRewardId, discountId: defaultGroup.discountId, }, }), ), );
149-168: Confirm intended behavior on upsert update: group attributes aren’t updatedOn update, only status is changed. If an enrollment already exists with different group/reward/discount IDs, this code will not move the partner into the selected group nor refresh reward IDs.
- If the goal is to avoid reassigning existing partners, this is fine.
- If you intend to align existing enrollments to the chosen default group, you’ll need an update path (or a separate migration/repair job).
Would you like me to draft a safe update strategy that updates only when groupId is null to avoid clobbering manual assignments?
apps/web/lib/partnerstack/import-partners.ts (2)
112-118: Nit: message says “leads” but condition checks customersThe condition checks partner.stats.CUSTOMER_COUNT, but the message says “No leads found…”. Align the message to avoid confusion.
- message: `No leads found for partner ${partner.email}`, + message: `No customers found for partner ${partner.email}`,
149-165: Confirm upsert semantics: update path does not set group/reward fieldsSame consideration as other importers: update only changes status. If that’s intentional (don’t reassign existing enrollments), good; if not, consider updating missing group/reward fields.
I can propose a guarded update that sets groupId only when null, if desired.
apps/web/lib/tolt/import-partners.ts (1)
151-167: Verify intended behavior: enrollment update path only sets statusAs with Rewardful/PartnerStack, the update path doesn’t propagate defaultGroupAttributes. If that’s by design to preserve existing group assignments, no action needed; otherwise, consider an update strategy.
Happy to propose a guarded update to fill missing group assignments without overwriting explicit ones.
apps/web/lib/actions/partners/create-program.ts (2)
126-144: LGTM: dynamic reward→group mapping on default group creationUsing REWARD_EVENT_COLUMN_MAPPING with a computed property to seed the default group with the first created reward is correct. Consider mirroring this in the upsert update branch for completeness if the row exists (edge case).
217-224: If logo is set post-commit, ensure emails use the updated programProgramWelcome currently uses the program object captured before post-commit updates. If you move the upload out of the transaction, pass the updated program (with logo) to ensure the email renders the final logo.
Do you want me to include a minimal refactor that switches program to a mutable let and updates it before waitUntil?
apps/web/scripts/migrations/backfill-partner-groups.ts (6)
142-149: Replace @ts-ignore with a typed, precomputed legacy check and remove the O(n·m) scanAccessing reward.default via @ts-ignore is brittle. Prefer computing a Set of legacy default reward IDs once and use it here. This removes the type hole and cuts per-group scans.
Apply this diff to rewrite the check:
- const hasDefaultReward = rewards.some( - (reward) => - // @ts-ignore (old reward schema) - reward.default && - (reward.id === group.saleRewardId || - reward.id === group.leadRewardId || - reward.id === group.clickRewardId), - ); + const hasDefaultReward = + (group.saleRewardId && defaultRewardIds.has(group.saleRewardId)) || + (group.leadRewardId && defaultRewardIds.has(group.leadRewardId)) || + (group.clickRewardId && defaultRewardIds.has(group.clickRewardId));Add this helper right after fetching rewards (see suggested diff on Lines 38-45):
type LegacyReward = (typeof rewards)[number] & { default?: boolean }; const defaultRewardIds = new Set( (rewards as LegacyReward[]).filter((r) => r.default).map((r) => r.id), );
38-45: Precompute legacy default reward IDs to support old schema without ts-ignoreBuild a Set once to detect legacy default rewards. This avoids type suppression and improves performance.
const rewards = await prisma.reward.findMany({ where: { programId: { in: groups.map((group) => group.programId), }, }, }); + // Build a Set of legacy default reward IDs (supports old reward schema safely) + type LegacyReward = (typeof rewards)[number] & { default?: boolean }; + const defaultRewardIds = new Set( + (rewards as LegacyReward[]).filter((r) => r.default).map((r) => r.id), + );
77-80: Add types to id counters for clarity and safetyExplicit types help avoid accidental string/number coercion and improve readability.
- const rewardIdCounts = {}; - const discountIdCounts = {}; - const programIdCounts = {}; + const rewardIdCounts: Record<string, number> = {}; + const discountIdCounts: Record<string, number> = {}; + const programIdCounts: Record<string, number> = {};
103-105: Avoid mutating Prisma result objects with ad-hoc keysWriting
group["updated_*"]onto a typed Prisma group object is unsafe and obscures data flow.Prefer local state:
// at the top of the map callback const updatedRewardIds: Partial<Record<"clickRewardId" | "leadRewardId" | "saleRewardId", string>> = {}; // when duplicating updatedRewardIds[rewardType] = newRewardId; // later const finalClickRewardId = updatedRewardIds.clickRewardId ?? group.clickRewardId; const finalLeadRewardId = updatedRewardIds.leadRewardId ?? group.leadRewardId; const finalSaleRewardId = updatedRewardIds.saleRewardId ?? group.saleRewardId; let finalDiscountId = group.discountId; if (newDiscountIdWasCreated) finalDiscountId = newDiscountId; // track similarly instead of mutating `group`Also applies to: 131-133
135-138: Deep-copy potential JSON columns on Discount as wellYou deep-copy Reward.modifiers but not Discount JSON fields. If Discount has any JSON/JSONB columns, consider deep-copying to avoid accidental shared references.
Would you like me to scan the Prisma schema for JSON fields on Discount and patch the duplication accordingly?
168-186: Optional: pre-index enrollments to avoid repeated O(n) filtering per groupFiltering
programEnrollmentsfor every group leads to O(G·E). For large datasets, build a Map keyed by a composite of the five IDs once, then lookups are O(1).Example outside this hunk:
// Before the groups.map: const enrollmentsByKey = new Map<string, string[]>(); for (const e of programEnrollments) { const key = `${e.programId}|${e.clickRewardId ?? ""}|${e.leadRewardId ?? ""}|${e.saleRewardId ?? ""}|${e.discountId ?? ""}`; const arr = enrollmentsByKey.get(key); if (arr) arr.push(e.id); else enrollmentsByKey.set(key, [e.id]); } // Inside the groups.map: const key = `${group.programId}|${group.clickRewardId ?? ""}|${group.leadRewardId ?? ""}|${group.saleRewardId ?? ""}|${group.discountId ?? ""}`; const ids = enrollmentsByKey.get(key) ?? [];apps/web/scripts/migrations/migrate-program-invites.ts (5)
61-74: Avoid unused variable and promise chaining; useawaitand avoid shadowinglink.
linkResis unused, and the.then((link) => ...)shadows the outerlink. Usingawaitimproves readability and error handling. Also logs should reference the updated link.Apply:
- const linkRes = await prisma.link - .update({ - where: { - id: link.id, - }, - data: { - programId: programInvite.programId, - partnerId: partner.id, - }, - }) - .then((link) => recordLink(link)); - - console.log(`Updated link ${link.id}`); + const updatedLink = await prisma.link.update({ + where: { id: link.id }, + data: { + programId: programInvite.programId, + partnerId: partner.id, + }, + }); + try { + await recordLink(updatedLink); + } catch (e) { + console.warn(`recordLink failed for link ${updatedLink.id}:`, e); + } + + console.log(`Updated link ${updatedLink.id}`);
139-148: Backfill may miss >5k sales; consider paginating until exhaustion.Limit 5000 risks truncation for active links. A simple loop to fetch pages until fewer than
limitare returned would make this idempotent and complete.Example helper (place near top or alongside this function):
async function getAllEventsPaginated(params: Omit<Parameters<typeof getEvents>[0], "page"> & { limit?: number }) { const limit = params.limit ?? 5000; let page = 1; const all: SaleEvent[] = []; // Guard against runaway loops const MAX_PAGES = 200; while (page <= MAX_PAGES) { const chunk = await getEvents({ ...params, page, limit }); all.push(...chunk); if (chunk.length < limit) break; page += 1; } return all; }Then replace the call:
const saleEvents = await getAllEventsPaginated({ workspaceId: workspace.id, linkId: link.id, event: "sales", interval: "all", limit: 5000, sortOrder: "desc", sortBy: "timestamp", });
150-171: Avoid hardcoding currency to USD; prefer event-provided currency with USD fallback.If SaleEvent carries currency, propagate it; otherwise default to "usd". This improves reporting accuracy.
Apply:
- currency: "usd", + currency: (e.sale?.currency?.toLowerCase?.() ?? "usd") as "usd",If
SaleEventdoesn’t include currency, ignore this change.
18-19:take: 10likely limits the migration; confirm intent.If this is for initial dry runs, consider batching until no records remain (or remove
take) before running in production.
10-11: Use path aliases in migration script for consistencyThe script already uses
@/lib/...imports (see lines 3–6), and bothgetEventsandrecordLinkare correctly exported underapps/web/lib. Switch these two imports to aliases:--- apps/web/scripts/migrations/migrate-program-invites.ts @@ -10,7 +10,7 @@ -import { getEvents } from "../../lib/analytics/get-events"; -import { recordLink } from "../../lib/tinybird"; +import { getEvents } from "@/lib/analytics/get-events"; +import { recordLink } from "@/lib/tinybird";[optional_refactors_recommended]
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (17)
apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts(0 hunks)apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts(0 hunks)apps/web/app/(ee)/api/programs/[programId]/route.ts(0 hunks)apps/web/lib/actions/partners/create-program.ts(5 hunks)apps/web/lib/actions/partners/invite-partner.ts(1 hunks)apps/web/lib/api/partners/create-and-enroll-partner.ts(4 hunks)apps/web/lib/api/programs/get-program-or-throw.ts(1 hunks)apps/web/lib/partnerstack/import-partners.ts(5 hunks)apps/web/lib/rewardful/import-campaign.ts(3 hunks)apps/web/lib/rewardful/import-partners.ts(6 hunks)apps/web/lib/rewardful/schemas.ts(1 hunks)apps/web/lib/tolt/import-partners.ts(5 hunks)apps/web/scripts/migrations/backfill-partner-groups.ts(1 hunks)apps/web/scripts/migrations/migrate-program-invites.ts(1 hunks)apps/web/scripts/migrations/migrate-rewards-remainder.ts(1 hunks)packages/prisma/schema/discount.prisma(0 hunks)packages/prisma/schema/reward.prisma(0 hunks)
💤 Files with no reviewable changes (5)
- packages/prisma/schema/reward.prisma
- packages/prisma/schema/discount.prisma
- apps/web/app/(ee)/api/programs/[programId]/route.ts
- apps/web/app/(ee)/api/programs/[programId]/rewards/route.ts
- apps/web/app/(ee)/api/programs/[programId]/discounts/route.ts
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-14T05:17:51.787Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/lib/actions/partners/delete-reward.ts:33-41
Timestamp: 2025-08-14T05:17:51.787Z
Learning: In the partner groups system, a rewardId can only belong to one group, establishing a one-to-one relationship between rewards and groups. This means using Prisma's `update` method (rather than `updateMany`) is appropriate when updating groups by rewardId.
Applied to files:
apps/web/scripts/migrations/backfill-partner-groups.tsapps/web/lib/rewardful/import-partners.tsapps/web/lib/api/partners/create-and-enroll-partner.tsapps/web/lib/rewardful/import-campaign.tsapps/web/lib/tolt/import-partners.tsapps/web/lib/partnerstack/import-partners.tsapps/web/lib/actions/partners/create-program.ts
📚 Learning: 2025-08-14T05:00:23.180Z
Learnt from: devkiran
PR: dubinc/dub#2735
File: apps/web/app/api/og/program/route.tsx:63-64
Timestamp: 2025-08-14T05:00:23.180Z
Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.
Applied to files:
apps/web/lib/tolt/import-partners.tsapps/web/lib/partnerstack/import-partners.ts
🧬 Code Graph Analysis (5)
apps/web/lib/rewardful/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/rewardful/types.ts (1)
RewardfulAffiliate(42-55)
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
apps/web/lib/api/groups/get-group-or-throw.ts (1)
getGroupOrThrow(4-51)
apps/web/lib/rewardful/import-campaign.ts (5)
apps/web/lib/rewardful/types.ts (1)
RewardfulImportPayload(102-104)apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/api/create-id.ts (1)
createId(57-66)apps/web/ui/colors.ts (1)
RESOURCE_COLORS(36-38)apps/web/lib/rewardful/importer.ts (1)
rewardfulImporter(45-45)
apps/web/lib/tolt/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/tolt/types.ts (1)
ToltAffiliate(25-25)
apps/web/lib/partnerstack/import-partners.ts (2)
apps/web/lib/zod/schemas/groups.ts (1)
DEFAULT_PARTNER_GROUP(9-13)apps/web/lib/partnerstack/types.ts (1)
PartnerStackPartner(21-21)
🔇 Additional comments (14)
apps/web/scripts/migrations/migrate-rewards-remainder.ts (1)
12-19: Confirm selection criteria: only enrollments with all three reward IDs nullThe filter currently requires all three fields to be null. If the intent was to also backfill enrollments that are missing any single event reward, adjust the predicate accordingly (e.g., OR across the three fields).
apps/web/lib/rewardful/schemas.ts (2)
14-18: Schema rename to groupId looks goodSwitching the payload from rewardId to groupId aligns with the PR’s group-centric flow. No structural issues in the schema.
10-18: No morerewardIdreferences in Rewardful import code
I searched the entire repo and found zero occurrences ofrewardIdunderapps/web/lib/rewardful. All remainingrewardIdhits are in migration scripts or in partner-reward UI/API code, not in the Rewardful import paths. You can consider this change complete—there are no in-repo producers or consumers still usingrewardIdfor import payloads.apps/web/lib/rewardful/import-campaign.ts (2)
98-100: Avoid non-null assertion on optional relation; handle absence explicitlysalePartnerGroup may be null if the reward isn’t assigned to any group. Avoid “!” to prevent masking undefined at runtime.
- groupId = existingReward.salePartnerGroup?.id!; + groupId = existingReward.salePartnerGroup?.id ?? null;If this case should never happen, add an explicit check and create or map a group instead of silently dropping groupId.
14-25: Don’t rely solely on slug “default”; include a fallback via defaultGroupIdQuerying the default group by slug can fail if a program is missing or has renamed the default group. Prefer using program.defaultGroupId as a primary key fallback.
Apply:
- const { workspaceId, groups } = await prisma.program.findUniqueOrThrow({ + const { workspaceId, defaultGroupId, groups } = await prisma.program.findUniqueOrThrow({ where: { id: programId, }, include: { groups: { where: { slug: DEFAULT_PARTNER_GROUP.slug, }, }, }, });You can then safely fall back to defaultGroupId later if groups is empty (see suggested changes around Lines 72–97).
⛔ Skipped due to learnings
Learnt from: devkiran PR: dubinc/dub#2735 File: apps/web/app/api/og/program/route.tsx:63-64 Timestamp: 2025-08-14T05:00:23.180Z Learning: In Dub's partner program system, the default partner group will always exist. When programs are created, a default partner group is automatically upserted using DEFAULT_PARTNER_GROUP constant, so accessing program.groups[0] in contexts where the default group is queried is safe.apps/web/lib/actions/partners/invite-partner.ts (2)
80-82: Pre-check for group context is goodGuarding against missing groupId and defaultGroupId avoids runtime issues downstream. This complements the deeper fallback logic introduced in create-and-enroll-partner.
84-95: Passing groupId through (without defaulting) aligns with new group-driven flowDelegating default resolution to createAndEnrollPartner is consistent with the refactor. Ensure createAndEnrollPartner hardens its own guard so outside callers can’t pass neither groupId nor defaultGroupId.
I’ve proposed an internal guard in create-and-enroll-partner.ts to cover this.
apps/web/lib/api/partners/create-and-enroll-partner.ts (1)
91-112: Correct usage: group-derived IDs on enrollment payloadUsing group.id and the group’s reward/discount IDs directly is the right move for the new group-centric model.
apps/web/lib/rewardful/import-partners.ts (1)
111-116: LGTM: queue propagation of groupId only when continuing partner importForwarding groupId only when action === "import-partners" is correct and avoids leaking it into subsequent “customers” import steps.
apps/web/lib/partnerstack/import-partners.ts (2)
24-33: LGTM: default group resolution and usageSelecting the default group via slug and deriving defaultGroupAttributes from program.groups[0] is consistent and aligns with the groups-first model.
Also applies to: 32-33
62-69: LGTM: correct propagation of group-derived reward/discount IDsBuilding and passing defaultGroupAttributes ensures enrollments are populated from the program’s default group.
apps/web/lib/tolt/import-partners.ts (2)
18-27: LGTM: default group selection is consistent with the new modelFiltering by DEFAULT_PARTNER_GROUP.slug and deriving defaultGroup from program.groups[0] is correct.
Also applies to: 26-27
63-79: Good use of Promise.allSettled to isolate per-partner failuresThis maintains batch progress even if some partner creations fail.
apps/web/scripts/migrations/migrate-program-invites.ts (1)
139-148: No changes needed: “sales” is the correct event valueThe
getEventscall uses theeventparameter validated byeventsQuerySchema, which is built on
EVENT_TYPES = ["clicks", "leads", "sales"](apps/web/lib/analytics/constants.ts:213).
Therefore passing"sales"(plural) is expected and no update is required.
Summary by CodeRabbit
New Features
Refactor
Chores