Display referral and group moves changelog#3426
Conversation
… update related schemas. This change includes renaming and restructuring of parameters in the workflow and route files to streamline partner group management.
- Introduced `GroupMoveRule` component to manage rules for moving partners between groups based on specific criteria. - Updated `GroupAdditionalSettings` to include the new `GroupMoveRule` component. - Enhanced `GroupSettingsFormSkeleton` with additional settings rows for group ID and slug. - Refactored `AmountInput` to utilize `InlineBadgePopoverAmountInput` for better input handling. - Added `UserArrowRight` icon for improved UI representation.
- Introduced `upsertGroupMoveWorkflow` to manage group move rules and workflows. - Updated `getGroupOrThrow` to include move rules in the group data. - Enhanced the PATCH route to handle move rules and integrate with the new workflow. - Refactored `GroupMoveRule` component to utilize react-hook-form for better form management. - Updated Zod schemas to include move rules in group definitions and updates. - Improved UI for group additional settings to accommodate new move rule functionality.
…urce type in activity log
Co-authored-by: Steven Tey <stevensteel97@gmail.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis comprehensive PR introduces group move rules, restructures workflow evaluation to support multiple conditions and a new "partnerMetricsUpdated" trigger, refactors discount code deletion, implements activity logging with group change tracking, and adds auto-provisioning of discount codes. Multiple API routes, database schemas, and workflow execution logic are refactored to support these interconnected features. Changes
Sequence Diagram(s)sequenceDiagram
actor Admin
participant API as API Route
participant Service as movePartnersToGroup
participant DB as Database
participant Queue as Qstash Queue
participant Email as Email Service
Admin->>API: PATCH /api/groups/[id]/partners (partnerIds)
API->>Service: movePartnersToGroup(workspaceId, programId, partnerIds, userId, group)
Service->>DB: Fetch program enrollments
Service->>DB: Update enrollments with new group + rewards
Service->>DB: Fetch updated enrollments with links
Service->>DB: Build activity log changeSet (old vs new group)
Service->>Queue: Trigger remap-discount-codes cron
Service->>Queue: Trigger draft bounty submission creation
Service->>Email: Queue batch partner notification emails
Service->>DB: Record activity logs
Service-->>API: Return updated count
API-->>Admin: 200 OK { count }
sequenceDiagram
participant Webhook as Stripe Webhook
participant Handler as Webhook Handler
participant WF as executeWorkflows
participant DB as Database
participant Workflow as Workflow Engine
Webhook->>Handler: POST /stripe/integration/webhook (sale event)
Handler->>Handler: Parse sale amount & conversions
Handler->>WF: executeWorkflows({ trigger: "partnerMetricsUpdated", reason: "sale", identity: {workspaceId, programId, partnerId}, metrics: {current: {saleAmount, conversions}} })
WF->>DB: Fetch workflows matching trigger & reason
WF->>DB: Fetch partner enrollment with metrics context
WF->>Workflow: For each workflow: evaluateWorkflowConditions({conditions, attributes})
Workflow-->>WF: boolean (conditions pass?)
alt Conditions Pass
WF->>Workflow: Execute matched actions (e.g., MoveGroup, SendCampaign)
Workflow->>DB: Perform action-specific updates
end
WF-->>Handler: Execution complete
Handler-->>Webhook: 200 OK
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ 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 |
There was a problem hiding this comment.
Actionable comments posted: 7
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/app/(ee)/api/cron/bounties/create-draft-submissions/route.ts (1)
134-160:⚠️ Potential issue | 🟡 MinorPre-existing risk:
conditionmay beundefinediftriggerConditionsis an empty array.Line 137 accesses
[0]on the parsed array without a guard. IftriggerConditionsis[],conditionisundefinedand line 151 (partner[condition.attribute]) throws at runtime.The migration to
evaluateWorkflowConditionsat lines 155-160 is itself correct — wrapping the single condition in an array matches the new API.Proposed defensive guard
const condition = z .array(workflowConditionSchema) .parse(bounty.workflow.triggerConditions)[0]; + if (!condition) { + return logAndRespond( + `Bounty ${bountyId} workflow has no trigger conditions.`, + ); + } + // Partners with their link metricsapps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)
132-153:⚠️ Potential issue | 🟡 MinorPotential orphaned workflow if the group update fails.
upsertGroupMoveRulescreates/updates a workflow in the DB (lines 132-136), then the resultingworkflowIdis persisted on the group at line 153. If theprisma.partnerGroup.updateat line 139 throws, the workflow record will exist but won't be referenced by any group, leaving an orphan.Consider wrapping both operations in a transaction, or performing the upsert inside the
Promise.allblock, or adding compensating cleanup on failure.
🤖 Fix all issues with AI agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts:
- Around line 149-159: The loop that calls createDiscountCode for each link
should not allow one failure to abort the rest; wrap each call to
createDiscountCode (inside the workspace.stripeConnectId conditional iterating
over links) in a per-link try/catch, catching and logging the error (include
link id and partner context) and continue to the next link so subsequent
discount codes still get created; ensure the catch does not rethrow so withCron
does not fail for a single link.
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx:
- Around line 463-491: handleUpdateMaxValue currently issues two separate
updates (returns a value for field.onChange and calls onUpdate({ operator:
"between" })), which can cause the operator and value to desync; change it to
perform an atomic update by not calling onUpdate separately and instead return
undefined when you need to update operator + value together and call onUpdate
once with both properties (e.g., onUpdate({ operator: "between", value: newRange
})) so the caller can skip field.onChange when the function returns undefined;
specifically modify handleUpdateMaxValue (use ruleOperator and convertedValue to
decide when to bundle operator:"between" with the new value) and adjust the
caller (handleChange / field.onChange) to only call field.onChange when
handleUpdateMaxValue returns a non-undefined value.
In `@apps/web/lib/api/activity-log/track-activity-log.ts`:
- Line 44: Replace the insecure console.log call that prints prettyPrint(inputs)
in track-activity-log.ts with a redacted, structured log using the existing
logger: do not serialize full inputs (which include userId, resourceId,
description); instead log only non-sensitive metadata such as inputs.length and
the set of action types (derive from inputs[].action) and, if needed, a hashed
or redacted user identifier. Use the same logger used in the error path (replace
console.log with logger.info or logger.debug) and remove or prevent printing of
prettyPrint(inputs) to stdout.
In `@apps/web/lib/api/groups/find-groups-with-matching-rules.ts`:
- Around line 55-59: The logic currently treats rule sets with no
sharedAttributes as non-conflicting by returning false; change the behavior so
that when sharedAttributes.length === 0 the function treats the rule sets as
overlapping and returns true instead. Locate the block that checks
sharedAttributes.length (the early-return branch in the conflict-detection
function) and replace the return false with return true (or remove the
early-return and let the general overlap logic handle disjoint attributes),
ensuring the function now treats independent constraints on different attributes
as potentially satisfiable simultaneously.
In `@apps/web/lib/api/partners/get-partner-users.ts`:
- Around line 27-31: The Prisma relation filter on notificationPreferences in
get-partner-users.ts (and similarly in workflows/partner-approved/route.ts)
currently excludes PartnerUser rows that have no PartnerNotificationPreferences
record; update the query using the notificationPreference variable to add an OR
branch that keeps users with notificationPreferences null (i.e., OR: [{
notificationPreferences: { [notificationPreference]: true } }, {
notificationPreferences: null }]) so users without a prefs record are not
filtered out, or alternatively ensure PartnerNotificationPreferences is always
created when creating a PartnerUser (adjust create logic used by
invites/accept/route.ts and seed paths) so that notificationPreferences is never
null; target the notificationPreferences relation filter and PartnerUser
creation code paths when applying the fix.
In `@apps/web/lib/api/workflows/execute-send-campaign-workflow.ts`:
- Line 75: The code is unsafely casting `condition as CampaignTriggerCondition`
before passing it to `getProgramEnrollments`; instead, validate and narrow the
runtime shape first: check that `condition.operator === "gte"` and `typeof
condition.value === "number"` (or use `campaignTriggerConditionSchema` to
validate) and only then cast/forward it, otherwise handle the unsupported
operators (e.g., throw a clear error or convert/skip); update the logic in
execute-send-campaign-workflow.ts around the `condition` variable so
`getProgramEnrollments` always receives a true numeric
`CampaignTriggerCondition`.
In `@apps/web/lib/zod/schemas/bounties.ts`:
- Around line 20-24: The bountyPerformanceConditionSchema currently uses
z.enum(WORKFLOW_ATTRIBUTES) which permits non-stats attributes; create a new
constant (e.g., BOUNTY_PERFORMANCE_ATTRIBUTES) listing only the supported stats:
"totalLeads", "totalConversions", "totalSaleAmount", and "totalCommissions",
then update bountyPerformanceConditionSchema to use
z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES) instead of WORKFLOW_ATTRIBUTES so
schema-level validation matches PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES and prevents
unsupported attributes like "partnerEnrolledDays" or "partnerJoined".
🟡 Minor comments (14)
apps/web/ui/shared/inline-badge-popover.tsx-339-350 (1)
339-350:⚠️ Potential issue | 🟡 MinorMoney-specific handlers are applied unconditionally to the
"number"type.
handleMoneyKeyDownandhandleMoneyInputChangeare invoked for all three types, including"number". If those handlers restrict input (e.g., limiting decimal places or blocking non-currency characters), they could produce unexpected behavior when the component is used for plain numeric condition values (e.g.,attributeType === "number"inrewards-logic.tsxLine 554).Consider guarding the calls:
Suggested fix
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); setIsOpen(false); } - handleMoneyKeyDown(e); + if (type !== "number") handleMoneyKeyDown(e); onKeyDown?.(e); }} onChange={(e) => { - handleMoneyInputChange(e); + if (type !== "number") handleMoneyInputChange(e); onChange?.(e); }}#!/bin/bash # Check what handleMoneyKeyDown and handleMoneyInputChange do to understand impact on "number" type fd "form-utils" --type f --exec cat {}apps/web/lib/api/groups/find-groups-with-matching-rules.ts-40-48 (1)
40-48:⚠️ Potential issue | 🟡 MinorSilent overwriting of duplicate rule attributes will cause incorrect conflict detection.
There is no schema-level or function-level validation preventing duplicate attributes within a rule set. When two conditions have the same attribute (e.g., two
totalLeadsrules),Map.set()silently overwrites the first condition. This discards data and causesdoRuleSetsOverlap()to produce incorrect results when checking for conflicts between rule sets.Add validation to
validateGroupMoveRules()to detect and reject duplicate attributes, or ensure the Zod schema enforces uniqueness per rule set.apps/web/lib/zod/schemas/workflows.ts-86-92 (1)
86-92:⚠️ Potential issue | 🟡 MinorAlign optional
maxin type and schema.
OperatorFnin types.ts (line 680) definescVasnumber | { min: number; max?: number }with optionalmax, but the workflow condition schema (lines 87-92) requiresmaxas non-optional. Thebetweenfunction's guard at line 58 (if (min == null || max == null)) handles this safely at runtime, but the type/schema mismatch creates inconsistency. Makemaxrequired inOperatorFnor optional in the schema to align them.apps/web/lib/api/groups/get-group-or-throw.ts-52-55 (1)
52-55:⚠️ Potential issue | 🟡 MinorNormalize
moveRulestonullinstead ofundefinedwhen no workflow exists.
group.workflow?.triggerConditionsevaluates toundefinedin JavaScript when the workflow isnullor doesn't exist. While API routes that parse the result throughGroupSchemamay coerceundefinedtonull, direct callers (e.g., action functions) receive the rawundefinedvalue. The tests consistently expectmoveRules: null, and this inconsistency could cause silent failures in code checkingmoveRules === null.Use the
?? nulloperator to explicitly normalize:Suggested fix
return { ...group, - moveRules: group.workflow?.triggerConditions, + moveRules: group.workflow?.triggerConditions ?? null, };apps/web/lib/api/partners/notify-partner-group-change.ts-22-38 (1)
22-38:⚠️ Potential issue | 🟡 MinorAdd
replyTofield to match the pattern used in other partner notification emails.The code is missing a
replyTofield in the email configuration. Other partner notification emails in the codebase consistently usereplyTo: program.supportEmail || "noreply"(see partner-approved route, invite-partner action, and notify-partner-commission). Add this field to line 44 for consistency:replyTo: program.supportEmail || "noreply",The
program.supportEmailis available sinceprogramis fully included whengetPartnerInviteRewardsAndBountiescallsgetGroupOrThrow({ includeExpandedFields: true }).apps/web/app/(ee)/api/cron/discount-codes/delete/route.ts-27-30 (1)
27-30:⚠️ Potential issue | 🟡 Minor
workspace.stripeConnectIdmay benull.
stripeConnectIdis a nullable field in the workspace schema. ThedisableStripeDiscountCodefunction acceptsstring | nullbut throws an error if the value is falsy. WhilewithCronwill catch this error, similar code in the codebase explicitly guards against null values (e.g., inapps/web/app/(ee)/api/discount-codes/route.ts). Add a null check before calling the Stripe utility to match the established pattern.🛡️ Suggested guard
+ if (!workspace.stripeConnectId) { + return logAndRespond( + `Workspace for program ${programId} has no Stripe Connect ID.`, + ); + } + const disabledDiscountCode = await disableStripeDiscountCode({ code, stripeConnectId: workspace.stripeConnectId, });apps/web/ui/activity-logs/referral-activity-item.tsx-46-53 (1)
46-53:⚠️ Potential issue | 🟡 MinorMissing
dayin timestamp format — dates will render without the day of the month.The
formatDatecall includesmonth,year,hour, andminutebut omitsday. This will produce timestamps like "Feb 2026, 2:30 PM" rather than "Feb 6, 2026, 2:30 PM".Proposed fix
{formatDate(log.createdAt, { month: "short", + day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", })}apps/web/ui/activity-logs/action-renderers/partner-group-changed-renderer.tsx-7-10 (1)
7-10:⚠️ Potential issue | 🟡 Minor
GroupChangeSet.oldand.newshould be nullable to match the data producer.In
build-change-set.ts, botholdandnewcan benull(when the respective enrollment has nopartnerGroup). Theas GroupChangeSetcast on line 21 hides this mismatch. While the runtime check on line 23 guards againstnewbeingnull, theoldfield is never guarded — accessinggroupChange.old.nameelsewhere would NPE.🔧 Proposed fix
interface GroupChangeSet { - old: Pick<GroupProps, "id" | "name">; - new: Pick<GroupProps, "id" | "name">; + old: Pick<GroupProps, "id" | "name"> | null; + new: Pick<GroupProps, "id" | "name"> | null; }apps/web/lib/api/workflows/execute-move-group-workflow.ts-37-47 (1)
37-47:⚠️ Potential issue | 🟡 Minor
findUniqueOrThrowwill surface as an unhandled Prisma exception in a background workflow.All other guard checks in this function use
console.error/console.log+returnto skip gracefully. If the enrollment doesn't exist (e.g., partner was unenrolled between trigger and execution), this will throw aPrismaClientKnownRequestErrorinstead of logging and skipping, which may produce noisy error reports or crash the workflow runner depending on how upstream handles it.Consider using
findUnique+ a null check for consistency with the rest of this function's error-handling style.Proposed fix
- const programEnrollment = await prisma.programEnrollment.findUniqueOrThrow({ + const programEnrollment = await prisma.programEnrollment.findUnique({ where: { partnerId_programId: { partnerId, programId, }, }, select: { groupId: true, }, }); + if (!programEnrollment) { + console.error( + `Program enrollment not found for partner ${partnerId} in program ${programId}. Skipping..`, + ); + return; + } +apps/web/lib/api/groups/upsert-group-move-rules.ts-50-63 (1)
50-63:⚠️ Potential issue | 🟡 MinorAdd server-side validation of
moveRulesor document the assumption that input is pre-validated.Validation occurs only at the client level in
group-additional-settings.tsxviavalidateGroupMoveRulesbefore the API call. The server-sideupsertGroupMoveRulesfunction receivesmoveRulesbut does not validate their structure, attributes, operators, or values. If invalid rules somehow bypass the client validation (e.g., direct API call), they will reach the persistence layer unchecked. Consider either callingvalidateGroupMoveRuleswithinupsertGroupMoveRulesfor defense-in-depth, or add a comment documenting that the function assumes pre-validated input from the API route.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx-529-535 (1)
529-535:⚠️ Potential issue | 🟡 MinorFloating-point rounding risk in currency conversion.
Number("19.99") * 100yields1998.9999…rather than1999. Since these are stored as integers (cents), consider usingMath.round:- return isCurrency ? numValue * 100 : numValue; + return isCurrency ? Math.round(numValue * 100) : numValue;apps/web/app/(ee)/api/cron/groups/remap-discount-codes/route.ts-151-154 (1)
151-154:⚠️ Potential issue | 🟡 MinorNon-null assertion on
link.programEnrollment!can throw at runtime.
programEnrollmentis selected as a nested relation (line 126–135) but is optional on theLinkmodel. If a link somehow lacks a program enrollment, this will throw a TypeError. Consider guarding or filtering.Suggested guard
for (const link of links) { + if (!link.programEnrollment?.partner) { + console.error(`Link ${link.id} has no program enrollment or partner.`); + continue; + } await createDiscountCode({ stripeConnectId: workspace.stripeConnectId, - partner: link.programEnrollment!.partner, + partner: link.programEnrollment.partner, link, discount: group.discount, });apps/web/lib/api/groups/move-partners-to-group.ts-90-189 (1)
90-189:⚠️ Potential issue | 🟡 MinorUnhandled failure inside
waitUntilsilently drops all side-effects.If either
prisma.link.findMany(line 92) or the secondprisma.programEnrollment.findMany(line 105) throws, the entirewaitUntilcallback rejects and none of the downstream tasks (remap, bounty creation, recordLink, notifications, activity log) execute. BecausewaitUntilswallows rejections, this would be invisible.Consider wrapping the body in a try/catch with error logging, or at least separating the independent fetches so a failure in one doesn't block the others.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx-118-126 (1)
118-126:⚠️ Potential issue | 🟡 Minor
errorisunknownin the catch block — accessing.messagemay fail type checking.TypeScript catch blocks type
errorasunknown. Direct access toerror.messageat Line 124 will cause a compile error with strict settings.Proposed fix
try { validateGroupMoveRules(data.moveRules); } catch (error) { - toast.error(error.message); + toast.error( + error instanceof Error ? error.message : "Invalid move rules", + ); return; }
🧹 Nitpick comments (27)
apps/web/scripts/migrations/migrate-workflow-triggers.ts (1)
19-19: Add error handling for clearer failure diagnostics.If the migration fails (e.g., invalid enum value, DB connection issue), the unhandled rejection may produce a confusing stack trace. A simple
.catch()makes failures explicit.Proposed fix
-main(); +main().catch((e) => { + console.error("Migration failed:", e); + process.exit(1); +});packages/ui/src/icons/user-clock.tsx (1)
13-30: Hardcoded#525252color prevents theming via CSScolor.Unlike
FileSend(which usesstroke="currentColor"), this icon has hardcodedstroke="#525252"andfill="#525252". This means the icon won't respond to parentcolorchanges, creating an inconsistency across the new icon set.If the design intentionally fixes the color, this is fine. Otherwise, consider using
currentColorfor both stroke and fill to allow theming.♻️ Suggested change for consistency
<path d="M9 7.25C10.519 7.25 11.75 6.019 11.75 4.5C11.75 2.981 10.519 1.75 9 1.75C7.481 1.75 6.25 2.981 6.25 4.5C6.25 6.019 7.481 7.25 9 7.25Z" - stroke="#525252" + stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> <path d="M9.39805 9.7856C9.26475 9.7758 9.13625 9.75 9.00035 9.75C6.44935 9.75 4.26135 11.28 3.29135 13.47C2.92635 14.295 3.37835 15.2439 4.23835 15.5149C5.27485 15.8423 6.61564 16.1218 8.15934 16.2041" - stroke="#525252" + stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" /> <path d="M14 10C11.7939 10 10 11.7944 10 14C10 16.2056 11.7939 18 14 18C16.2061 18 18 16.2056 18 14C18 11.7944 16.2061 10 14 10ZM16.3125 14.9502C16.1934 15.2398 15.9141 15.415 15.6191 15.415C15.5234 15.415 15.4277 15.3969 15.3339 15.3588L13.7148 14.6938C13.4336 14.5781 13.25 14.3042 13.25 14V12.25C13.25 11.8359 13.5859 11.5 14 11.5C14.4141 11.5 14.75 11.8359 14.75 12.25V13.4971L15.9043 13.9712C16.2871 14.1284 16.4707 14.5669 16.3125 14.9502Z" - fill="#525252" + fill="currentColor" />apps/web/lib/stripe/create-stripe-discount-code.ts (1)
61-74: Code grows by 2 characters per retry — acceptable but worth noting.Each retry appends
nanoid(2)to the already-extendedcurrentCode, so after 2 retries the code is 4 characters longer than the original. WithMAX_ATTEMPTS = 3this is bounded and fine, but ifMAX_ATTEMPTSever increases, the code could exceed Stripe's promotion code length limit.💡 Alternative: always append to the original code to keep length predictable
+ const originalCode = code; let attempt = 0; let currentCode = code; while (attempt < MAX_ATTEMPTS) { try { ... } catch (error: any) { ... attempt++; if (attempt >= MAX_ATTEMPTS) { throw error; } - const newCode = `${currentCode}${nanoid(2)}`; + const newCode = `${originalCode}${nanoid(2 * attempt)}`;apps/web/ui/partners/discounts/add-edit-discount-sheet.tsx (1)
471-490: Auto-provision toggle doesn't mark the form as dirty.Other
setValuecalls in this file (e.g., lines 397–399, 441–443) pass{ shouldDirty: true }. The auto-provision toggle on line 481 omits this option, which means toggling it alone won't enable a dirty-form guard if one is added later, andformState.isDirtywon't reflect the change.Proposed fix
- fn={() => setValue("autoProvision", !autoProvision)} + fn={() => setValue("autoProvision", !autoProvision, { shouldDirty: true })}apps/web/lib/api/discounts/delete-discount-code.ts (1)
43-56:Promise.allSettledsilently swallows enqueue failures — consider logging settled rejections.If individual
queue.enqueueJSONcalls reject,Promise.allSettledcaptures them but nothing logs the failures. This could make debugging silent Stripe cleanup failures difficult.Proposed improvement
for (const chunkOfCodes of chunks) { - await Promise.allSettled( + const results = await Promise.allSettled( chunkOfCodes.map((discountCode) => queue.enqueueJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/discount-codes/delete`, method: "POST", body: { code: discountCode.code, programId: discountCode.programId, }, }), ), ); + + const failures = results.filter((r) => r.status === "rejected"); + if (failures.length > 0) { + console.error( + `Failed to enqueue ${failures.length} discount code deletion(s).`, + failures.map((f) => (f as PromiseRejectedResult).reason), + ); + } }apps/web/lib/zod/schemas/workflows.ts (1)
44-63: Runtime type guards are sound but hide schema-level mismatches.The
gteandbetweenoperator functions gracefully returnfalsewhen the value type doesn't match expectations. However, theworkflowConditionSchema(lines 83-93) allows anyoperator/valuecombination to pass validation — e.g.,{ operator: "between", value: 5 }or{ operator: "gte", value: { min: 1, max: 10 } }would parse successfully but silently evaluate tofalseat runtime.Consider using a discriminated union on
operatorso invalid pairings are rejected at parse time rather than silently failing evaluation.💡 Suggested schema structure
-export const workflowConditionSchema = z.object({ - attribute: z.enum(WORKFLOW_ATTRIBUTES), - operator: z.enum(WORKFLOW_COMPARISON_OPERATORS).default("gte"), - value: z.union([ - z.number(), - z.object({ - min: z.number(), - max: z.number(), - }), - ]), -}); +export const workflowConditionSchema = z.discriminatedUnion("operator", [ + z.object({ + attribute: z.enum(WORKFLOW_ATTRIBUTES), + operator: z.literal("gte"), + value: z.number(), + }), + z.object({ + attribute: z.enum(WORKFLOW_ATTRIBUTES), + operator: z.literal("between"), + value: z.object({ + min: z.number(), + max: z.number(), + }), + }), +]);apps/web/app/(ee)/api/cron/groups/remap-default-links/route.ts (1)
225-248: Consider parallelizing the two independent QStash publish calls.The
sync-utmandremap-discount-codesjobs are independent of each other and are currently awaited sequentially. Parallelizing would reduce the overall latency of this cron handler.♻️ Proposed parallel scheduling
- const syncUtmJob = await qstash.publishJSON({ + const [syncUtmJob, remapDiscountCodesJob] = await Promise.all([ + qstash.publishJSON({ url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/sync-utm`, body: { groupId, partnerIds, }, - }); - - console.log( - `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`, - ); - - const remapDiscountCodesJob = await qstash.publishJSON({ - url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`, - body: { - programId, - partnerIds, - groupId, - }, - }); + }), + qstash.publishJSON({ + url: `${APP_DOMAIN_WITH_NGROK}/api/cron/groups/remap-discount-codes`, + body: { + programId, + partnerIds, + groupId, + }, + }), + ]); console.log( - `Scheduled remap-discount-codes job for group ${groupId}: ${prettyPrint(remapDiscountCodesJob)}`, + `Scheduled sync-utm job for group ${groupId}: ${prettyPrint(syncUtmJob)}`, + ); + console.log( + `Scheduled remap-discount-codes job for group ${groupId}: ${prettyPrint(remapDiscountCodesJob)}`, );apps/web/tests/partner-groups/index.test.ts (2)
142-257: Good test coverage for the move rules lifecycle.The create → augment → remove flow with GET verification after each mutation is solid. One minor observation:
The destructuring block to strip
applicationFormData,applicationFormPublishedAt,landerData,landerPublishedAt,programfromfetchedGroupis repeated three times (lines 165–172, 210–217, 242–249). Consider extracting a small helper to reduce duplication:const stripExpandedFields = ({ applicationFormData, applicationFormPublishedAt, landerData, landerPublishedAt, program, ...rest }: GroupWithProgramProps) => rest;
151-158: Consider asserting the PATCH response body includesmoveRules.The tests verify persistence via a follow-up GET, which is good for end-to-end confidence. However, asserting the PATCH response body itself would catch serialization issues earlier and ensure the API contract is correct on writes too. If the PATCH response intentionally omits
moveRules, this is fine as-is.apps/web/ui/partners/partner-info-cards.tsx (1)
306-322: Nit:partnerGroupHistorySheetrenders outside theisEnrolledguard.The "View history" button is gated by
isEnrolled, but the sheet node on line 322 is rendered unconditionally within this section. It's functionally harmless (sheet defaults to closed, and there's no trigger for non-enrolled partners), but moving it inside theisEnrolledblock would make the intent clearer.Suggested placement
{isEnrolled && ( <div className="flex min-h-7 items-center justify-between"> <h3 className="text-content-emphasis text-sm font-semibold"> Group </h3> {hasActivityLogs && ( <Button variant="outline" text="View history" className="h-7 w-fit rounded-lg px-1.5 text-xs font-medium text-neutral-400" onClick={() => setGroupHistoryOpen(true)} /> )} </div> )} - {partnerGroupHistorySheet} + {isEnrolled && partnerGroupHistorySheet} {partner ? (apps/web/app/(ee)/api/cron/partners/deactivate/route.ts (1)
50-52: Redundant identity map.
discountCodes.map((dc) => dc)is a no-op. Simplify:Suggested fix
- const discountCodes = programEnrollments.flatMap(({ discountCodes }) => - discountCodes.map((dc) => dc), - ); + const discountCodes = programEnrollments.flatMap( + ({ discountCodes }) => discountCodes, + );apps/web/lib/api/groups/validate-group-move-rules.ts (2)
17-17: Nit: redundantundefinedcheck.
== nullalready matches bothnullandundefinedvia loose equality, so the explicit=== undefinedis redundant. Same pattern repeats on lines 41 and 47.Suggested simplification
- if (rule.value == null || rule.value === undefined) { + if (rule.value == null) {And similarly for lines 41 and 47:
- if (min == null || min === undefined || isNaN(min) || min <= 0) { + if (min == null || isNaN(min) || min <= 0) {- if (max == null || max === undefined || isNaN(max) || max <= 0) { + if (max == null || isNaN(max) || max <= 0) {
8-60: Consider handling unknown operators.Rules with operators other than
"gte"or"between"pass validation with only the attribute/value presence checks. If other operators are supported (or could be introduced), their value constraints won't be enforced.If the set of supported operators is fixed, a catch-all
elsethrowing on unrecognized operators would make this future-proof.apps/web/lib/api/partners/notify-partner-group-change.ts (1)
40-60: MissingreplyToon queued emails.Other email-sending flows in this codebase (e.g., the deactivate cron at
route.tsline 79) setreplyTo: program.supportEmail || "noreply". This notification doesn't include areplyTo, so replies from partners will go to the default sender. If this is intentional, ignore — otherwise, consider threadingprogram.supportEmailthrough.apps/web/app/api/activity-logs/route.ts (1)
29-31: Consider usingselectinstead ofincludefor the user relation.
include: { user: true }fetches all user columns from the database. While theactivityLogSchema.parse()on line 34 strips extra fields from the response, usingselecton the user relation would avoid pulling unnecessary data (potentially sensitive fields) from the DB in the first place.♻️ Suggested change
include: { - user: true, + user: { + select: { + id: true, + name: true, + email: true, + image: true, + }, + }, },packages/prisma/schema/workflow.prisma (1)
5-6: ConfirmclickRecordedremoval is tracked.The TODO is noted, but ensure there's an issue or follow-up task to actually run the migration and remove this enum value. Stale enum values in Prisma schemas tend to linger if not explicitly tracked.
apps/web/app/(ee)/api/groups/[groupIdOrSlug]/route.ts (1)
438-440: Batch thedeleteDiscountCodescall instead of mapping individual items.
deleteDiscountCodesalready accepts an array, so calling it once with the full list avoids N separate DBdeleteManycalls and N separate queue-enqueue rounds.♻️ Proposed fix
- ...discountCodesToDelete.map((discountCode) => - deleteDiscountCodes(discountCode), - ), + discountCodesToDelete.length > 0 && + deleteDiscountCodes(discountCodesToDelete),apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-settings.tsx (2)
64-72: SWR revalidation may reset the form and discard unsaved edits.When SWR revalidates (e.g., on window focus),
groupgets a new object reference, triggeringreset()even if the data hasn't changed. This would silently clear any in-progress edits. Consider comparing against previous values before resetting, or disabling revalidation options likerevalidateOnFocusfor this hook.♻️ Example: guard reset with a shallow check
useEffect(() => { if (group) { - reset({ - name: group.name, - slug: group.slug, - color: group.color, - }); + reset( + { + name: group.name, + slug: group.slug, + color: group.color, + }, + { keepDirtyValues: true }, + ); } }, [group, reset]);
106-144: Non-null assertion ongroup!is safe but implicit.On Line 125,
group!.slugrelies on theisLoadingguard at Line 106. The assertion is technically safe here sinceisLoading === falseimpliesgroup !== null, but it's an implicit contract.apps/web/lib/swr/use-activity-logs.ts (1)
15-22:workspaceId!produces"undefined"string when workspace hasn't loaded.
searchParamsis built eagerly before the SWR gate checksworkspaceId. While SWR won't fetch with the malformed URL, the non-null assertion on Line 17 silently coercesundefinedto the string"undefined"in the query params.Consider deferring
searchParamsconstruction or guarding onworkspaceId:♻️ Suggested fix
- const searchParams = query - ? new URLSearchParams({ - workspaceId: workspaceId!, - resourceType: query.resourceType, - resourceId: query.resourceId, - ...(query.action && { action: query.action }), - }).toString() - : ""; + const searchParams = + query && workspaceId + ? new URLSearchParams({ + workspaceId, + resourceType: query.resourceType, + resourceId: query.resourceId, + ...(query.action && { action: query.action }), + }).toString() + : "";apps/web/lib/api/groups/upsert-group-move-rules.ts (1)
72-76: Consider usingWorkflowTrigger.partnerMetricsUpdateddirectly instead of a type assertion.Line 73 uses
"partnerMetricsUpdated" as WorkflowTrigger. Since this trigger value exists as an enum member, referencing the enum directly would be more type-safe and catch renames at compile time.Suggested change
- trigger: "partnerMetricsUpdated" as WorkflowTrigger, + trigger: WorkflowTrigger.partnerMetricsUpdated,apps/web/app/(ee)/api/workflows/partner-approved/route.ts (1)
160-227: Step 2 lacks workflow logger usage, unlike all other steps.Every other step in this workflow uses
logger.info/logger.errorfor observability (e.g., lines 76–78, 231–233, 323–325). Step 2 only usesconsole.erroron line 221, making it harder to trace in the workflow logging infrastructure.Consider adding
logger.infoat entry/exit and replacingconsole.errorwithlogger.errorfor consistency.Suggested improvement
// Step 2: Auto-provision discount code if enabled await context.run("create-discount-codes", async () => { + logger.info({ + message: "Started executing workflow step 'create-discount-codes'.", + data: input, + }); + if (!groupId) { return; }} catch (error) { - console.error( - `Failed to create discount code for link ${link.id}:`, - error, - ); + logger.error({ + message: `Failed to create discount code for link ${link.id}.`, + data: error, + }); }apps/web/lib/api/groups/move-partners-to-group.ts (1)
64-68: Parameter reassignment narrowspartnerIdssilently.Line 68 reassigns
partnerIdsto only the partners that actually have enrollments. This is correct behavior but can be surprising to readers sincepartnerIdsin the rest of the function no longer matches the caller's input. A distinct variable name would make the intent clearer.Suggestion
- partnerIds = programEnrollments.map(({ partnerId }) => partnerId); + const enrolledPartnerIds = programEnrollments.map(({ partnerId }) => partnerId);Then use
enrolledPartnerIdsinupdateMany, link queries, and thePromise.allSettledblock.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-additional-settings.tsx (1)
61-65: MissingforminuseEffectdependency array.React's exhaustive-deps lint rule would flag this. While
formfromuseFormis stable across renders, explicitly including it silences the warning and is more correct.Proposed fix
useEffect(() => { if (group) { form.reset({ moveRules: group.moveRules ?? [] }); } - }, [group]); + }, [group, form]);apps/web/lib/api/activity-log/track-activity-log.ts (1)
26-30: Hardcoded"referral.created"action string — consider using the typed constant.This magic string creates a coupling that could drift from the schema. If
ActivityLogActionis an inferred union type, consider extracting a constant or referencing the schema to keep things in sync.apps/web/lib/types.ts (2)
777-799:WorkflowIdentityandPartnerMetricsare not exported — intentional?These interfaces are used as property types of the exported
WorkflowContext. Callers constructingWorkflowContextobjects (e.g.,track-lead.ts,track-sale.ts) rely on structural typing to satisfy the shape, which works. However, if any caller needs to independently type a variable asWorkflowIdentityorPartnerMetrics, they won't be able to import these types.This is fine with structural typing, but exporting them would improve developer ergonomics for consumers.
678-681:OperatorFnsignature updated to support range-based comparison.The
cVparameter now acceptsnumber | { min: number; max?: number }, enabling "between" operator support. The parameter namesaVandcVare terse — consideractualValueandconditionValuefor clarity.
| // Create discount code for the partner default links | ||
| if (workspace.stripeConnectId) { | ||
| for (const link of links) { | ||
| await createDiscountCode({ | ||
| stripeConnectId: workspace.stripeConnectId, | ||
| partner: link.programEnrollment!.partner, | ||
| link, | ||
| discount: group.discount, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this comment.
Missing per-link error handling — one failure aborts remaining discount code creation.
In the partner-approved workflow (route.ts lines 212–226), each createDiscountCode call is wrapped in try/catch so a single failure doesn't block others. Here, if createDiscountCode throws for one link, the entire cron handler fails (propagated to withCron) and no subsequent links get codes.
Suggested fix: add per-link try/catch
if (workspace.stripeConnectId) {
for (const link of links) {
- await createDiscountCode({
- stripeConnectId: workspace.stripeConnectId,
- partner: link.programEnrollment!.partner,
- link,
- discount: group.discount,
- });
+ try {
+ await createDiscountCode({
+ stripeConnectId: workspace.stripeConnectId,
+ partner: link.programEnrollment!.partner,
+ link,
+ discount: group.discount,
+ });
+ } catch (error) {
+ console.error(
+ `Failed to create discount code for link ${link.id}:`,
+ error,
+ );
+ }
}
}🤖 Prompt for AI Agents
In `@apps/web/app/`(ee)/api/cron/groups/remap-discount-codes/route.ts around lines
149 - 159, The loop that calls createDiscountCode for each link should not allow
one failure to abort the rest; wrap each call to createDiscountCode (inside the
workspace.stripeConnectId conditional iterating over links) in a per-link
try/catch, catching and logging the error (include link id and partner context)
and continue to the next link so subsequent discount codes still get created;
ensure the catch does not rethrow so withCron does not fail for a single link.
| const handleUpdateMaxValue = ( | ||
| currentFieldValue: ValueType, | ||
| convertedValue: number, | ||
| onUpdate: (updates: Partial<WorkflowCondition>) => void, | ||
| ruleOperator: WorkflowCondition["operator"], | ||
| ): ValueType => { | ||
| if (isRangeValue(currentFieldValue)) { | ||
| const rangeValue = currentFieldValue as RangeValue; | ||
| const min = | ||
| rangeValue.min != null && rangeValue.min > 0 ? rangeValue.min : undefined; | ||
| return ( | ||
| min != null ? { min, max: convertedValue } : { max: convertedValue } | ||
| ) as any; | ||
| } | ||
|
|
||
| // Create range from current value | ||
| const currentMin = getMinValue(currentFieldValue); | ||
| const newRange = | ||
| currentMin != null && currentMin > 0 | ||
| ? { min: currentMin, max: convertedValue } | ||
| : { max: convertedValue }; | ||
|
|
||
| // Update operator if needed | ||
| if (ruleOperator === "gte") { | ||
| onUpdate({ operator: "between" }); | ||
| } | ||
|
|
||
| return newRange as any; | ||
| }; |
There was a problem hiding this comment.
Dual state update may cause operator/value desync.
handleUpdateMaxValue both calls onUpdate({ operator: "between" }) (line 487) and returns a new value that the caller passes to field.onChange (line 412). These two updates target overlapping form state (moveRules.${index}):
onUpdatetriggersuseFieldArray.update(index, { ...rule, operator: "between" }), which replaces the entire rule (including the oldvalue).field.onChange(newValue)sets onlymoveRules.${index}.value.
If updateRule fires after field.onChange (or vice versa due to React batching), the new value could be overwritten by the stale rule snapshot in { ...rule, ...updatedRule }.
Consider updating both operator and value atomically in a single onUpdate call:
Suggested fix
const handleUpdateMaxValue = (
currentFieldValue: ValueType,
convertedValue: number,
onUpdate: (updates: Partial<WorkflowCondition>) => void,
ruleOperator: WorkflowCondition["operator"],
): ValueType => {
if (isRangeValue(currentFieldValue)) {
const rangeValue = currentFieldValue as RangeValue;
const min =
rangeValue.min != null && rangeValue.min > 0 ? rangeValue.min : undefined;
return (
min != null ? { min, max: convertedValue } : { max: convertedValue }
) as any;
}
// Create range from current value
const currentMin = getMinValue(currentFieldValue);
const newRange =
currentMin != null && currentMin > 0
? { min: currentMin, max: convertedValue }
: { max: convertedValue };
// Update operator if needed
if (ruleOperator === "gte") {
- onUpdate({ operator: "between" });
+ onUpdate({ operator: "between", value: newRange as any });
}
- return newRange as any;
+ return ruleOperator === "gte" ? undefined : (newRange as any);
};Note: The caller (handleChange at line 412) would need to skip field.onChange when the return is undefined (i.e., the update was handled atomically). This may need further adjustment to fit cleanly.
🤖 Prompt for AI Agents
In
`@apps/web/app/app.dub.co/`(dashboard)/[slug]/(ee)/program/groups/[groupSlug]/settings/group-move-rules.tsx
around lines 463 - 491, handleUpdateMaxValue currently issues two separate
updates (returns a value for field.onChange and calls onUpdate({ operator:
"between" })), which can cause the operator and value to desync; change it to
perform an atomic update by not calling onUpdate separately and instead return
undefined when you need to update operator + value together and call onUpdate
once with both properties (e.g., onUpdate({ operator: "between", value: newRange
})) so the caller can skip field.onChange when the function returns undefined;
specifically modify handleUpdateMaxValue (use ruleOperator and convertedValue to
decide when to bundle operator:"between" with the new value) and adjust the
caller (handleChange / field.onChange) to only call field.onChange when
handleUpdateMaxValue returns a non-undefined value.
| })), | ||
| }); | ||
|
|
||
| console.log("[trackActivityLog] Activity log created", prettyPrint(inputs)); |
There was a problem hiding this comment.
Logging full activity log inputs to stdout may expose user identifiers.
prettyPrint(inputs) serializes the entire input array including userId, resourceId, and description to console.log. In production, this data flows to stdout and may be captured by log aggregators without redaction. Consider logging only the count and action types, or routing this through the structured logger used in the error path.
Proposed fix
- console.log("[trackActivityLog] Activity log created", prettyPrint(inputs));
+ console.log(
+ `[trackActivityLog] Created ${inputs.length} activity log(s): ${inputs.map((i) => i.action).join(", ")}`,
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| console.log("[trackActivityLog] Activity log created", prettyPrint(inputs)); | |
| console.log( | |
| `[trackActivityLog] Created ${inputs.length} activity log(s): ${inputs.map((i) => i.action).join(", ")}`, | |
| ); |
🤖 Prompt for AI Agents
In `@apps/web/lib/api/activity-log/track-activity-log.ts` at line 44, Replace the
insecure console.log call that prints prettyPrint(inputs) in
track-activity-log.ts with a redacted, structured log using the existing logger:
do not serialize full inputs (which include userId, resourceId, description);
instead log only non-sensitive metadata such as inputs.length and the set of
action types (derive from inputs[].action) and, if needed, a hashed or redacted
user identifier. Use the same logger used in the error path (replace console.log
with logger.info or logger.debug) and remove or prevent printing of
prettyPrint(inputs) to stdout.
| // If there are no shared attributes, the rule sets cannot conflict | ||
| // (e.g., one checks conversions, the other checks leads - they're independent) | ||
| if (sharedAttributes.length === 0) { | ||
| return false; | ||
| } |
There was a problem hiding this comment.
Logic bug: disjoint attributes should be treated as overlapping, not non-conflicting.
The comment on line 34 states the intent: "Two rule sets conflict if there exists ANY set of attribute values that would satisfy both simultaneously." However, when rule sets have no shared attributes, the function returns false (no conflict), which is incorrect.
Consider:
- Rule set 1:
{totalLeads >= 10}— satisfied when leads ≥ 10 (conversions unconstrained) - Rule set 2:
{totalConversions >= 5}— satisfied when conversions ≥ 5 (leads unconstrained)
A partner with 10 leads and 5 conversions satisfies both simultaneously, so they do conflict. The current code would return false, allowing both groups to have these rules and causing ambiguity during partner moves.
The fix: when there are no shared attributes, rule sets always overlap (independent constraints on different dimensions never exclude each other).
Proposed fix
// If there are no shared attributes, the rule sets cannot conflict
// (e.g., one checks conversions, the other checks leads - they're independent)
if (sharedAttributes.length === 0) {
- return false;
+ return true;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // If there are no shared attributes, the rule sets cannot conflict | |
| // (e.g., one checks conversions, the other checks leads - they're independent) | |
| if (sharedAttributes.length === 0) { | |
| return false; | |
| } | |
| // If there are no shared attributes, the rule sets cannot conflict | |
| // (e.g., one checks conversions, the other checks leads - they're independent) | |
| if (sharedAttributes.length === 0) { | |
| return true; | |
| } |
🤖 Prompt for AI Agents
In `@apps/web/lib/api/groups/find-groups-with-matching-rules.ts` around lines 55 -
59, The logic currently treats rule sets with no sharedAttributes as
non-conflicting by returning false; change the behavior so that when
sharedAttributes.length === 0 the function treats the rule sets as overlapping
and returns true instead. Locate the block that checks sharedAttributes.length
(the early-return branch in the conflict-detection function) and replace the
return false with return true (or remove the early-return and let the general
overlap logic handle disjoint attributes), ensuring the function now treats
independent constraints on different attributes as potentially satisfiable
simultaneously.
| ...(notificationPreference && { | ||
| notificationPreferences: { | ||
| [notificationPreference]: true, | ||
| }, | ||
| }), |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -name "schema.prisma" -type f | head -5Repository: dubinc/dub
Length of output: 92
🏁 Script executed:
rg "PartnerNotificationPreferences" --type=ts -A 5 -B 5 | head -200Repository: dubinc/dub
Length of output: 801
🏁 Script executed:
cat -n apps/web/lib/api/partners/get-partner-users.ts | head -50Repository: dubinc/dub
Length of output: 1461
🏁 Script executed:
cat ./packages/prisma/schema/schema.prisma | grep -A 20 "model PartnerUser"Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
cat ./packages/prisma/schema/schema.prisma | grep -A 15 "model PartnerNotificationPreferences"Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
rg "PartnerNotificationPreferences.*create" --type=ts -B 3 -A 3 | head -100Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
wc -l ./packages/prisma/schema/schema.prismaRepository: dubinc/dub
Length of output: 95
🏁 Script executed:
rg "model PartnerUser" ./packages/prisma/schema/schema.prisma -A 20Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
rg "model PartnerNotificationPreferences" ./packages/prisma/schema/schema.prisma -A 15Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
rg "PartnerNotificationPreferences" --type=ts --type=js | grep -E "(create|new)" -i | head -50Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
cat ./packages/prisma/schema/schema.prismaRepository: dubinc/dub
Length of output: 2810
🏁 Script executed:
rg "PartnerNotificationPreferences" --type=ts --type=js -l | head -20Repository: dubinc/dub
Length of output: 100
🏁 Script executed:
rg "model PartnerUser|model PartnerNotificationPreferences" ./packages/prisma/schema/ -A 20Repository: dubinc/dub
Length of output: 2395
🏁 Script executed:
sed -n '/model Partner/,/^model /p' ./packages/prisma/schema/schema.prisma | head -80Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
rg "PartnerNotificationPreferences" --type=ts --type=js -B 5 -A 5 | grep -E "(create|upsert|connect)" -i -B 3 -A 3 | head -100Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
rg "partnerUser.*create|PartnerUser.*create" --type=ts --type=js -A 10 | head -80Repository: dubinc/dub
Length of output: 1556
🏁 Script executed:
rg "partnerUser.create" --type=ts --type=js -B 3 -A 10 | head -150Repository: dubinc/dub
Length of output: 1879
🏁 Script executed:
rg "notificationPreferences.*create|notificationPreferences.*upsert" --type=ts --type=js -B 5 -A 5Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
rg "getPartnerUsers" --type=ts --type=js -B 3 -A 3 | head -100Repository: dubinc/dub
Length of output: 4844
🏁 Script executed:
rg "partnerUser.create|partnerUser.createMany" --type=ts --type=js -A 15 | grep -E "notificationPreferences|create|data" | head -80Repository: dubinc/dub
Length of output: 865
🏁 Script executed:
cat -n apps/web/app/\(ee\)/api/partner-profile/invites/accept/route.ts | grep -A 20 "partnerUser.create"Repository: dubinc/dub
Length of output: 655
🏁 Script executed:
rg "partnerUser\.create\(|partnerUser\.createMany\(" --type=ts --type=js -B 2 -A 15 | grep -v node_modulesRepository: dubinc/dub
Length of output: 2596
🏁 Script executed:
find . -type f -name "*.ts" -o -name "*.js" | xargs grep -l "partnerUser" | xargs grep -l "create" | grep -v node_modules | head -20Repository: dubinc/dub
Length of output: 1003
🏁 Script executed:
rg "partnerUser\.findMany|partnerUser\.findFirst" --type=ts -A 20 | grep -E "where|notificationPreference" -B 5 -A 5 | head -80Repository: dubinc/dub
Length of output: 5100
🏁 Script executed:
cat -n apps/web/app/\(ee\)/api/workflows/partner-approved/route.ts | grep -B 5 -A 15 "notificationPreferences"Repository: dubinc/dub
Length of output: 690
🏁 Script executed:
rg "invite-partner-user" --type=ts -A 30 | grep -E "create|notification"Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
cat -n apps/web/lib/api/partners/invite-partner-user.ts | head -100Repository: dubinc/dub
Length of output: 2343
🏁 Script executed:
rg "fix-broken-partner-users" --type=ts -A 50 | head -100Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
cat -n apps/web/scripts/fix-broken-partner-users.tsRepository: dubinc/dub
Length of output: 1345
🏁 Script executed:
rg "partnerNotificationPreferences" ./packages/prisma/schema/notification.prisma -B 2 -A 10Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
cat ./packages/prisma/schema/notification.prisma | grep -A 15 "model PartnerNotificationPreferences"Repository: dubinc/dub
Length of output: 457
Users without notificationPreferences records will be silently excluded from results.
The Prisma relation filter notificationPreferences: { [pref]: true } excludes any partnerUser row without an associated PartnerNotificationPreferences record entirely—not just those where the flag is false. Since the schema defines the relation as optional (notificationPreferences PartnerNotificationPreferences?) and invites/accept/route.ts is the only code path that creates preference records (while seed.ts creates users without them), users without preferences will be filtered out silently.
This affects both get-partner-users.ts (lines 27-31) and workflows/partner-approved/route.ts (line 247-248).
Ensure PartnerNotificationPreferences records are always created when a PartnerUser is created, or update the filter to include users without a preferences record:
OR: [
{ notificationPreferences: { [notificationPreference]: true } },
{ notificationPreferences: null },
]🤖 Prompt for AI Agents
In `@apps/web/lib/api/partners/get-partner-users.ts` around lines 27 - 31, The
Prisma relation filter on notificationPreferences in get-partner-users.ts (and
similarly in workflows/partner-approved/route.ts) currently excludes PartnerUser
rows that have no PartnerNotificationPreferences record; update the query using
the notificationPreference variable to add an OR branch that keeps users with
notificationPreferences null (i.e., OR: [{ notificationPreferences: {
[notificationPreference]: true } }, { notificationPreferences: null }]) so users
without a prefs record are not filtered out, or alternatively ensure
PartnerNotificationPreferences is always created when creating a PartnerUser
(adjust create logic used by invites/accept/route.ts and seed paths) so that
notificationPreferences is never null; target the notificationPreferences
relation filter and PartnerUser creation code paths when applying the fix.
| partnerId, | ||
| groupIds: campaign.groups.map(({ groupId }) => groupId), | ||
| condition, | ||
| condition: condition as CampaignTriggerCondition, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Check the return type of parseWorkflowConfig and the definition of CampaignTriggerCondition
echo "=== parseWorkflowConfig return type ==="
ast-grep --pattern 'function parseWorkflowConfig($_) {
$$$
}'
echo -e "\n=== CampaignTriggerCondition schema ==="
rg -n "campaignTriggerConditionSchema" --type=ts -C5Repository: dubinc/dub
Length of output: 5518
🏁 Script executed:
# Find workflowConditionSchema definition
echo "=== workflowConditionSchema definition ==="
rg -n "workflowConditionSchema" --type=ts -A10 | head -50
echo -e "\n=== Context around line 75 in execute-send-campaign-workflow.ts ==="
sed -n '60,85p' apps/web/lib/api/workflows/execute-send-campaign-workflow.tsRepository: dubinc/dub
Length of output: 3918
🏁 Script executed:
# Check what WORKFLOW_COMPARISON_OPERATORS contains
echo "=== WORKFLOW_COMPARISON_OPERATORS definition ==="
rg -n "WORKFLOW_COMPARISON_OPERATORS" --type=ts -B2 -A5 | head -30
# Also check WORKFLOW_ATTRIBUTES to be thorough
echo -e "\n=== WORKFLOW_ATTRIBUTES definition ==="
rg -n "WORKFLOW_ATTRIBUTES" --type=ts -B2 -A5 | head -30Repository: dubinc/dub
Length of output: 5148
Remove unsafe type assertion or add type validation before casting.
The cast condition as CampaignTriggerCondition is unsafe. workflowConditionSchema allows operator: "between" and value: { min, max }, but campaignTriggerConditionSchema only accepts operator: "gte" with value: number. A workflow condition with operator "between" would pass the assertion at compile time but fail at runtime in getProgramEnrollments, which expects a numeric value.
Add runtime validation (e.g., check condition.operator === "gte" and typeof condition.value === "number") before the cast, or refactor the type to properly narrow the condition before passing it to getProgramEnrollments.
🤖 Prompt for AI Agents
In `@apps/web/lib/api/workflows/execute-send-campaign-workflow.ts` at line 75, The
code is unsafely casting `condition as CampaignTriggerCondition` before passing
it to `getProgramEnrollments`; instead, validate and narrow the runtime shape
first: check that `condition.operator === "gte"` and `typeof condition.value ===
"number"` (or use `campaignTriggerConditionSchema` to validate) and only then
cast/forward it, otherwise handle the unsupported operators (e.g., throw a clear
error or convert/skip); update the logic in execute-send-campaign-workflow.ts
around the `condition` variable so `getProgramEnrollments` always receives a
true numeric `CampaignTriggerCondition`.
| export const bountyPerformanceConditionSchema = z.object({ | ||
| attribute: z.enum(WORKFLOW_ATTRIBUTES), | ||
| operator: z.literal("gte").default("gte"), | ||
| value: z.number(), | ||
| }); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, find the bounties.ts file and workflows.ts file
find . -type f -name "bounties.ts" -o -name "workflows.ts" | grep -E "(zod|schemas)" | head -20Repository: dubinc/dub
Length of output: 132
🏁 Script executed:
# Check the WORKFLOW_ATTRIBUTES definition in workflows.ts
rg -n "WORKFLOW_ATTRIBUTES" --type=ts -A 15 | head -80Repository: dubinc/dub
Length of output: 8427
🏁 Script executed:
# Get the full bounties.ts schema file to see the current state
cat -n apps/web/lib/zod/schemas/bounties.ts | head -150Repository: dubinc/dub
Length of output: 5812
🏁 Script executed:
# Find PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES definition
rg -n "PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES" --type=ts -B 2 -A 20 | head -100Repository: dubinc/dub
Length of output: 8793
🏁 Script executed:
# Check WORKFLOW_ATTRIBUTES definition
rg -n "export const WORKFLOW_ATTRIBUTES" --type=ts -A 30Repository: dubinc/dub
Length of output: 2248
🏁 Script executed:
# Check bounty creation endpoints for validation
rg -n "bountyPerformanceConditionSchema" --type=tsRepository: dubinc/dub
Length of output: 965
🏁 Script executed:
# Find and display PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES
find . -name "performance-bounty-scope-attributes.ts" -o -name "*performance*bounty*" | head -20Repository: dubinc/dub
Length of output: 257
🏁 Script executed:
# Get the full content of performance-bounty-scope-attributes file
cat -n apps/web/lib/api/bounties/performance-bounty-scope-attributes.ts 2>/dev/null || rg -n "PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES" --type=ts -B 2 -A 20 | grep -v "bounty-logic\|bounty-performance" | head -50Repository: dubinc/dub
Length of output: 402
🏁 Script executed:
# Check bounty creation endpoint to see if there's server-side validation
fd -e ts "bounties.*route\|create.*bounty" | head -10Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
# Search for bounty creation/update routes
find . -path "*/bounties/*route.ts" | head -20Repository: dubinc/dub
Length of output: 662
🏁 Script executed:
# Check if there's server-side validation that restricts bounty performance condition attributes
rg -n "performanceCondition" --type=ts -B 3 -A 3 | grep -E "route\.ts|bounty.*create|bounty.*update" | head -30Repository: dubinc/dub
Length of output: 2731
🏁 Script executed:
# Look for bounty creation routes specifically
fd -e ts "\[bountyId\]|bounties" | xargs rg -l "createBountySchema\|bountyPerformanceConditionSchema" | head -10Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
# Check the full bounties creation route
cat -n apps/web/app/(ee)/api/bounties/route.ts | head -250Repository: dubinc/dub
Length of output: 199
🏁 Script executed:
# Check if there's any validation of performanceCondition attributes at creation time
rg -n "performanceCondition" apps/web/app/\(ee\)/api/bounties/route.ts -B 5 -A 5Repository: dubinc/dub
Length of output: 2104
🏁 Script executed:
# Check the generatePerformanceBountyName function to see what attributes it handles
cat -n apps/web/lib/api/bounties/generate-performance-bounty-name.tsRepository: dubinc/dub
Length of output: 1125
🏁 Script executed:
# Check for any validation of bounty performance condition attributes on the server
rg -n "PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES\[" --type=ts -B 2 -A 2Repository: dubinc/dub
Length of output: 3535
🏁 Script executed:
# Check if WORKFLOW_ATTRIBUTE_TRIGGER includes partnerEnrolledDays/partnerJoined
cat -n apps/web/lib/zod/schemas/workflows.ts | grep -A 10 "WORKFLOW_ATTRIBUTE_TRIGGER"Repository: dubinc/dub
Length of output: 580
🏁 Script executed:
# Check WORKFLOW_ATTRIBUTE_TRIGGER access at line 227 - does it have safety checks?
sed -n '215,235p' apps/web/app/\(ee\)/api/bounties/route.tsRepository: dubinc/dub
Length of output: 666
🏁 Script executed:
# Verify the type signature of BountyPerformanceCondition
rg -n "type BountyPerformanceCondition\|interface BountyPerformanceCondition" --type=ts -A 5Repository: dubinc/dub
Length of output: 36
🏁 Script executed:
# Check if there's any validation that would catch invalid attributes in the workflow creation
rg -n "WORKFLOW_ATTRIBUTE_TRIGGER\[" --type=ts -B 3 -A 3Repository: dubinc/dub
Length of output: 1860
Restrict bounty performance condition attributes to valid types.
z.enum(WORKFLOW_ATTRIBUTES) allows "partnerEnrolledDays" and "partnerJoined", which are not supported for bounty performance conditions. Bounties only support stats-based attributes: totalLeads, totalConversions, totalSaleAmount, and totalCommissions. Without schema-level restriction, invalid attributes could bypass the UI and cause runtime errors when accessing PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES.
Define a dedicated constant for bounty performance attributes:
Suggested fix
+const BOUNTY_PERFORMANCE_ATTRIBUTES = [
+ "totalLeads",
+ "totalConversions",
+ "totalSaleAmount",
+ "totalCommissions",
+] as const;
+
export const bountyPerformanceConditionSchema = z.object({
- attribute: z.enum(WORKFLOW_ATTRIBUTES),
+ attribute: z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES),
operator: z.literal("gte").default("gte"),
value: z.number(),
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const bountyPerformanceConditionSchema = z.object({ | |
| attribute: z.enum(WORKFLOW_ATTRIBUTES), | |
| operator: z.literal("gte").default("gte"), | |
| value: z.number(), | |
| }); | |
| const BOUNTY_PERFORMANCE_ATTRIBUTES = [ | |
| "totalLeads", | |
| "totalConversions", | |
| "totalSaleAmount", | |
| "totalCommissions", | |
| ] as const; | |
| export const bountyPerformanceConditionSchema = z.object({ | |
| attribute: z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES), | |
| operator: z.literal("gte").default("gte"), | |
| value: z.number(), | |
| }); |
🤖 Prompt for AI Agents
In `@apps/web/lib/zod/schemas/bounties.ts` around lines 20 - 24, The
bountyPerformanceConditionSchema currently uses z.enum(WORKFLOW_ATTRIBUTES)
which permits non-stats attributes; create a new constant (e.g.,
BOUNTY_PERFORMANCE_ATTRIBUTES) listing only the supported stats: "totalLeads",
"totalConversions", "totalSaleAmount", and "totalCommissions", then update
bountyPerformanceConditionSchema to use z.enum(BOUNTY_PERFORMANCE_ATTRIBUTES)
instead of WORKFLOW_ATTRIBUTES so schema-level validation matches
PERFORMANCE_BOUNTY_SCOPE_ATTRIBUTES and prevents unsupported attributes like
"partnerEnrolledDays" or "partnerJoined".
Summary by CodeRabbit
Release Notes
New Features
Documentation