-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Improve manual commission flow to reuse existing events if present #2853
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds an event-source toggle and optional identifier toggles to the commission UI; extends manual commission action to optionally reuse or duplicate Tinybird events when transferring customers; introduces Tinybird helpers/ingest schemas, zod schema fields, and related types; removes the PartnerDetailsSheet component. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor User
participant UI as CreateCommission UI
participant Server as createManualCommissionAction
participant TB as Tinybird
participant DB as Database
User->>UI: Open form, choose event source & optional IDs
UI->>User: Render conditional fields or events table
User->>UI: Submit payload (partnerId, commissionType, useExistingEvents, optional IDs)
UI->>Server: POST createManualCommission(payload)
alt useExistingEvents = true AND transferring customer
Server->>TB: getCustomerEventsTB(customerId, linkIds?)
TB-->>Server: return events
Server->>Server: generate new customer id (duplicate)
loop per event
Server->>TB: write duplicated event (timestamped)
end
Server->>DB: mark old customer, create new customer, update link stats
Server->>DB: create partner commissions for duplicated events
else useExistingEvents = true AND same partner
Server->>DB: create commissions referencing existing event ids
else useExistingEvents = false
Server->>TB: recordClickZod (click) and write lead/sale events
Server->>DB: create commissions for new events
end
Server-->>UI: success / failure
UI-->>User: show toast
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ 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. Comment |
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Show resolved
Hide resolved
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: 6
🧹 Nitpick comments (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)
106-116: Accordion state references a removed panel ("sale")You’re setting openAccordions to include "sale", but there is no "sale" accordion item anymore. Remove it to avoid inconsistent UI state.
if (commissionType === "custom") { setOpenAccordions([...baseValues, "commission"]); } else if (commissionType === "sale") { - setOpenAccordions([...baseValues, "customer", "sale"]); + setOpenAccordions([...baseValues, "customer"]); } else if (commissionType === "lead") { setOpenAccordions([...baseValues, "customer"]); }
181-187: Fix state setter typos and add selection stateRename misspelled setters to avoid confusion and add state for the selected existing event.
-const [existingEvents, setExistingEventss] = useState< +const [existingEvents, setExistingEvents] = useState< { type: string; date?: string; amount?: number }[] >([]); -const [existingEventsLoading, setExistingEventssLoading] = useState(false); +const [existingEventsLoading, setExistingEventsLoading] = useState(false); + +const [selectedExistingEvent, setSelectedExistingEvent] = useState< + { type: string; date?: string; amount?: number } | null +>(null); + +useEffect(() => { + setSelectedExistingEvent(null); +}, [customerId, commissionType, useExistingEvents]);
662-691: Apply money keydown handling to sale amount for consistencyMatch the custom commission amount behavior and prevent invalid number input.
render={({ field }) => ( <input {...field} type="number" onWheel={(e) => e.currentTarget.blur()} + onKeyDown={handleMoneyKeyDown} + step="0.01"
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx(5 hunks)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)
20-20: LGTM: Loading spinner importImport looks correct and is used below.
454-454: Copy tweak is fine“Customer and commission details” reads better given the merged flow.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
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: 2
♻️ Duplicate comments (6)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (6)
120-127: Accordion auto-open references a non-existent "sale" sectionKeep only "customer". This was flagged already; fix here too.
Apply this diff:
- } else if (commissionType === "sale") { - setOpenAccordions([...baseValues, "customer", "sale"]); + } else if (commissionType === "sale") { + setOpenAccordions([...baseValues, "customer"]);
528-550: Event buttons lacked click handlers previously—now addressedPrior review noted missing onClick. Ensure the new handler also sets eventIds as shown above so the server receives them.
563-646: Lead toggles unreachable when collapsedSwitches live inside collapsing containers. Move switches outside; animate only inputs. Prior comment covers this; please adopt it.
712-768: Invoice ID toggle unreachable (same pattern)Keep switch visible; animate input only. Prior comment covers exact diff.
769-824: Product ID toggle unreachable (same pattern)Mirror the invoice fix. Prior comment covers exact diff.
521-555: Wire selection: set eventIds and prefill fields; fix header grammarButtons don’t update form state. Persist selection and set form values. Also fix “events” → “event”.
Apply this diff:
- <h3 className="mb-3 text-sm font-medium text-neutral-900"> - Select an existing events - </h3> + <h3 className="mb-3 text-sm font-medium text-neutral-900"> + Select an existing event + </h3> @@ - {existingEvents.map((event, index) => ( + {existingEvents.map((event, index) => ( <button key={index} type="button" - className="flex w-full items-center justify-between rounded-md bg-white p-3 text-sm hover:bg-neutral-50 focus:bg-neutral-100 focus:outline-none" + onClick={() => { + setValue("eventIds", [event.id], { shouldDirty: true }); + if (commissionType === "sale") { + // TODO: confirm units; convert cents->dollars if needed + setValue("saleEventDate", event.date ?? null, { shouldDirty: true }); + setValue("saleAmount", event.amount ?? null, { shouldDirty: true }); + } else if (commissionType === "lead") { + setValue("leadEventDate", event.date ?? null, { shouldDirty: true }); + } + }} + className="flex w-full items-center justify-between rounded-md bg-white p-3 text-sm hover:bg-neutral-50 focus:bg-neutral-100 focus:outline-none" > <div> <span className="font-medium"> - {event.type} + {event.type.charAt(0).toUpperCase() + event.type.slice(1)} </span>
🧹 Nitpick comments (5)
apps/web/lib/zod/schemas/commissions.ts (1)
152-153: Constrain and document eventIds; enforce non-empty and basic hygieneRight now eventIds accepts empty arrays and empty strings. Tighten validation and add a simple cross‑field guard so event reuse can’t be combined with the “custom” flow.
Apply this diff:
- eventIds: z.array(z.string()).nullish(), + // When provided, we will reuse existing events to create commissions. + eventIds: z + .array(z.string().min(1, "Event ID cannot be empty")) + .min(1, "Select at least one event") + .max(50, "Too many events selected") + .nullish(),Optionally add a refine to disallow mixing custom commission fields with event reuse:
export const createCommissionSchema = z.object({ workspaceId: z.string(), partnerId: z.string(), @@ productId: z.string().nullish(), - eventIds: z.array(z.string()).nullish(), + eventIds: z + .array(z.string().min(1)) + .min(1) + .max(50) + .nullish(), }).refine( - (data) => true, + (data) => !data.eventIds || (!data.date && !data.amount), { - message: "", + message: + "Do not combine eventIds with the one-time commission fields (date/amount).", + path: ["eventIds"], }, );apps/web/lib/actions/partners/create-manual-commission.ts (1)
123-137: Guard dummy-lead creation behind “no eventIds” earlier to reduce branching complexityThe else-if added is good, but with the early return above we can simplify: treat eventIds first, then fall back to existing/dummy lead. This reduces accidental double-processing.
Apply this diff to re-order the checks:
- // if there is an existing lead event + no custom lead details were provided + // If eventIds present, reuse and return (handled below). + // If there is an existing lead event + no custom lead details were provided // we can use that leadEvent's existing details - if ( + if ( !leadEventDate && !leadEventName && existingLeadEvent && existingLeadEvent.data.length > 0 ) { leadEvent = leadEventSchemaTB.parse(existingLeadEvent.data[0]); - } else if (!eventIds || eventIds.length === 0) { + } else { + // dummy path executes only when eventIds not provided (the eventIds branch returns early) + if (eventIds && eventIds.length > 0) { + // handled later + } else { // else, if there's no existing lead event and there is also no custom leadEventName/Date // we need to create a dummy click + lead event (using the customer's country if available) const dummyRequest = new Request(link.url, { headers: new Headers({apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)
171-189: Submission gating ignores eventIds; enable submit when reusing eventsWhen using existing events, sale should not require manual saleAmount; lead should only require linkId/customerId. Incorporate eventIds and useExistingEvents.
Apply this diff:
- const shouldDisableSubmit = useMemo(() => { + const shouldDisableSubmit = useMemo(() => { if (!partnerId) { return true; } @@ - if (commissionType === "sale") { - return !linkId || !customerId || !saleAmount; + if (commissionType === "sale") { + const hasExisting = useExistingEvents && (watch("eventIds")?.length ?? 0) > 0; + return !linkId || !customerId || (!saleAmount && !hasExisting); } @@ - }, [commissionType, partnerId, linkId, customerId, saleAmount, amount]); + }, [commissionType, partnerId, linkId, customerId, saleAmount, amount, useExistingEvents, watch]);
191-228: Race-safe fetch; include workspaceId in deps and check response.okAdd a cancellation guard/AbortController, include workspaceId in deps, and handle non‑200s.
Apply this diff:
- useEffect(() => { + useEffect(() => { + let cancelled = false; + const controller = new AbortController(); if ( !customerId || !commissionType || !workspaceId || commissionType === "custom" ) { setExistingEvents([]); setExistingEventsLoading(false); return; } const fetchEvents = async () => { setExistingEventsLoading(true); try { const searchParams = new URLSearchParams({ workspaceId, customerId, event: `${commissionType}s`, }); - const response = await fetch(`/api/events?${searchParams.toString()}`, { - method: "GET", - }); - - setExistingEvents(await response.json()); + const response = await fetch(`/api/events?${searchParams.toString()}`, { + method: "GET", + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Failed to fetch events: ${response.status}`); + const data = await response.json(); + if (!cancelled) setExistingEvents(data ?? []); } catch (error) { - setExistingEvents([]); + if (!cancelled) setExistingEvents([]); } finally { - setExistingEventsLoading(false); + if (!cancelled) setExistingEventsLoading(false); } }; fetchEvents(); - }, [customerId, commissionType]); + return () => { + cancelled = true; + controller.abort(); + }; + }, [customerId, commissionType, workspaceId]);
229-230: Remove debug log before mergeconsole.log leaks internal state to devtools.
Apply this diff:
- console.log({ existingEvents }); +
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx(5 hunks)apps/web/lib/actions/partners/create-manual-commission.ts(4 hunks)apps/web/lib/zod/schemas/commissions.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)
apps/web/lib/swr/use-rewards.ts (1)
useRewards(6-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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (5)
apps/web/lib/actions/partners/create-manual-commission.ts (3)
66-81: Custom commission path LGTMThe custom (“one-time”) commission branch is self‑contained and returns early. No issues.
276-331: Avoid double-counting stats for reused eventsThe link/customer stat updates assume new events. When reusing existing events, skipping these increments (we return earlier) is correct; ensure this invariant stays documented to prevent future regressions.
Add a short comment above the early return noting that stats updates are intentionally skipped when eventIds are used.
23-26: Handle partner/customer mismatch when reusing eventsWhen eventIds is provided but the existing customer's partnerId != provided partnerId, the code must either clone the customer into this workspace (copy essential fields and duplicate event IDs) or reject the request — currently unhandled.
Location: apps/web/lib/actions/partners/create-manual-commission.ts (lines 23–26)
Confirm desired behavior: create+duplicate vs reject. I can implement the guarded create-and-duplicate path once confirmed.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)
286-304: Commission type toggle LGTMConditional options derived from rewards look correct and resilient to loading states.
662-709: Money input handling consistent; verify unit conversion for reused eventsWhen pre-filling saleAmount from existing events, confirm whether fetched amounts are cents or dollars to avoid 100x errors. Frontend multiplies by 100 before send.
If the events API returns cents, set saleAmount to event.amount / 100 on selection.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
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
♻️ Duplicate comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
271-333: Duplicate-events branch doesn’t create commissions or trigger workflows; also ignores reuse when link matches
- No
createPartnerCommissioncalls for duplicated events → user sees analytics but no commissions.- No
executeWorkflowsafter duplication.- Reuse is skipped entirely when
customer.linkId === linkId(events on same link):shouldDuplicateEventsprevents any handling, but users still expect commissions to be created for selectedeventIds.- Minor:
console.log(events)risks leaking PII in logs.- Fragile: inferring
eventTypefromsaleAmountcan mismatch selectedeventIds(e.g., reusing lead events while passing a sale amount, or vice versa).Fix by always handling
eventIds(create commissions), and only duplicating records when the link differs. Also emit the appropriate workflow trigger and remove the log.Apply this diff:
- // Duplicate the customer events - const shouldDuplicateEvents = - eventIds && eventIds.length > 0 && customer.linkId !== linkId; - - if (shouldDuplicateEvents) { - const eventType = !saleAmount ? "leads" : "sales"; + // Reuse or duplicate the selected events (always create commissions). + const hasEventIds = + Array.isArray(eventIds) && eventIds.length > 0; + if (hasEventIds) { + const shouldDuplicate = customer.linkId !== linkId; + const eventType = !saleAmount ? "leads" : "sales"; // NOTE: UI must ensure selected ids match this type. @@ - const pipe = tb.buildPipe({ + const pipe = tb.buildPipe({ pipe: "v2_events", parameters: eventsFilterTB, data: z.any(), }); @@ - const events = response.data; + const events = response.data; @@ - if (events.length === 0) { + if (events.length === 0) { throw new Error("Selected events not found."); } - - - // Duplicate the events for the new customer - if (eventType === "leads") { - await Promise.all( - events.map((event) => - recordLeadWithTimestamp({ - ...event, - customer_id: customerId, // TODO: should be new customerId - link_id: link.id, - }), - ), - ); - } else if (eventType === "sales") { - await Promise.all( - events.map((event) => - recordSaleWithTimestamp({ - ...event, - customer_id: customerId, // TODO: should be new customerId - link_id: link.id, - }), - ), - ); - } - - console.log(events); + // 1) Duplicate events only if needed (link mismatch). + if (shouldDuplicate) { + if (eventType === "leads") { + await Promise.allSettled( + events.map((event: any) => + recordLeadWithTimestamp({ + ...event, + customer_id: customerId, // TODO: replace with new customerId when partner mismatch is handled + link_id: link.id, + }), + ), + ); + } else { + await Promise.allSettled( + events.map((event: any) => + recordSaleWithTimestamp({ + ...event, + customer_id: customerId, // TODO: replace with new customerId when partner mismatch is handled + link_id: link.id, + }), + ), + ); + } + } + + // 2) Create commissions for each selected (or duplicated) event. + if (eventType === "leads") { + await Promise.allSettled( + events.map((event: any) => + createPartnerCommission({ + event: "lead", + programId, + partnerId, + linkId, + eventId: event.event_id, + customerId, + amount: 0, + quantity: 1, + createdAt: new Date(event.timestamp), + user, + context: { + customer: { country: customer.country }, + }, + }), + ), + ); + await executeWorkflows({ + trigger: WorkflowTrigger.leadRecorded, + programId, + partnerId, + }); + } else { + await Promise.allSettled( + events.map((event: any) => + createPartnerCommission({ + event: "sale", + programId, + partnerId, + linkId, + eventId: event.event_id, + customerId, + amount: typeof event.amount === "number" ? event.amount : saleAmount ?? 0, + quantity: 1, + invoiceId, + currency: (event as any).currency ?? "usd", + createdAt: new Date(event.timestamp), + user, + context: { + customer: { country: customer.country }, + sale: { productId }, + }, + }), + ), + ); + await executeWorkflows({ + trigger: WorkflowTrigger.saleRecorded, + programId, + partnerId, + }); + } }
🧹 Nitpick comments (4)
apps/web/lib/zod/schemas/analytics.ts (2)
321-323: Remove non-existent key from omit() mask
pageis not part ofanalyticsFilterTB’s shape; passing it toomit()will break type inference. Drop it.Apply this diff:
-export const eventsFilterTB = analyticsFilterTB - .omit({ granularity: true, timezone: true, page: true, sortBy: true, }) +export const eventsFilterTB = analyticsFilterTB + .omit({ granularity: true, timezone: true, sortBy: true })
329-330: Accept CSV for eventIds (parity with linkIds/folderIds)Other filters accept both CSV string and array. Mirror that for
eventIdsto ease query construction and TB piping.Apply this diff:
- eventIds: z.array(z.string()).optional(), + eventIds: z + .union([z.string(), z.array(z.string())]) + .transform((v) => (Array.isArray(v) ? v : v.split(","))) + .optional(),apps/web/lib/actions/partners/create-manual-commission.ts (2)
300-301: Avoid PII in server logsRemove
console.log(events); events may include customer metadata.Apply this diff:
- console.log(events); + // Intentionally no logging of raw events to avoid PII in server logs.
133-221: Potential double-commission when mixing eventIds and synthetic lead/saleIf
eventIdsare provided and the branch above also created a synthetic lead (dummy click+lead) and a sale (whensaleAmount && leadEvent), users could get extra commissions. Once the eventIds path is in place, consider short‑circuiting earlier synthetic creation wheneventIdsare present.Would you like a patch to move the eventIds handling before the synthetic lead/sale logic and return early after completion?
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/lib/actions/partners/create-manual-commission.ts(4 hunks)apps/web/lib/analytics/types.ts(1 hunks)apps/web/lib/zod/schemas/analytics.ts(1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/zod/schemas/analytics.ts (1)
packages/utils/src/constants/misc.ts (1)
PAGINATION_LIMIT(31-31)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
apps/web/lib/zod/schemas/analytics.ts (1)
eventsFilterTB(321-331)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (2)
apps/web/lib/analytics/types.ts (1)
55-56: LGTM: type surface aligned
eventIds?: string[]matches the intended usage on the client and the TB schema addition. No further changes needed.apps/web/lib/actions/partners/create-manual-commission.ts (1)
276-297: Verify TB pipe parameter contract for v2_eventsFile: apps/web/lib/actions/partners/create-manual-commission.ts (lines 276–297)
rg failed to locate the pipe definition (error: "unrecognized file type: tsx"); confirm Tinybird pipe v2_events accepts an eventIds parameter and that filtering by eventType + eventIds is supported. If it doesn't, query by eventIds only or split requests by eventType.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
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
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
158-173: Avoid creating dummy lead/sale when eventIds is providedWith eventIds, the function still creates synthetic click/lead (and possibly a sale) before the duplication block. This yields extra events/commissions. Early-return after processing eventIds.
Covered by the early-return in the larger diff.
Also applies to: 192-253
♻️ Duplicate comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
303-361: Handle eventIds first: add reuse path, create commissions, and early-returnCurrently, eventIds are ignored unless partner mismatch, and even then no commissions are created. Also, deriving eventType from saleAmount is brittle. Process eventIds up-front, reuse when partner/link match, duplicate with new IDs when migrating, create commissions in both cases, trigger workflows, and return early.
Apply this diff to replace the whole block:
- // Duplicate the customer events - const shouldDuplicateEvents = - eventIds && eventIds.length > 0 && customer.linkId !== linkId; - - if (shouldDuplicateEvents) { - const eventType = !saleAmount ? "leads" : "sales"; - - // Find the events by the eventIds - const { startDate, endDate } = getStartEndDates({ - interval: "all", - }); - - const pipe = tb.buildPipe({ - pipe: "v2_events", - parameters: eventsFilterTB, - data: z.any(), - }); - - const response = await pipe({ - workspaceId: workspace.id, - eventType, - sortBy: "timestamp", - offset: 0, - limit: 100, - start: startDate.toISOString().replace("T", " ").replace("Z", ""), - end: endDate.toISOString().replace("T", " ").replace("Z", ""), - eventIds, - }); - - const events = response.data; - - if (events.length === 0) { - throw new Error("Selected events not found."); - } - - // Duplicate the events for the new customer - if (eventType === "leads") { - await Promise.all( - events.map((event) => - recordLeadWithTimestamp({ - ...event, - customer_id: customer.id, - link_id: link.id, - }), - ), - ); - } else if (eventType === "sales") { - await Promise.all( - events.map((event) => - recordSaleWithTimestamp({ - ...event, - customer_id: customer.id, - link_id: link.id, - }), - ), - ); - } - } + // Process provided eventIds: reuse or duplicate, then create commissions, then return. + if (Array.isArray(eventIds) && eventIds.length > 0) { + const { startDate, endDate } = getStartEndDates({ interval: "all" }); + const pipe = tb.buildPipe({ + pipe: "v2_events", + parameters: eventsFilterTB, + data: z.any(), + }); + // Try leads first, then sales, so we don't rely on saleAmount to infer type. + const fetchByType = (eventType: "leads" | "sales") => + pipe({ + workspaceId: workspace.id, + eventType, + sortBy: "timestamp", + offset: 0, + limit: eventIds.length, + start: startDate.toISOString().replace("T", " ").replace("Z", ""), + end: endDate.toISOString().replace("T", " ").replace("Z", ""), + eventIds, + }); + + let response = await fetchByType("leads"); + if (!response.data?.length) { + response = await fetchByType("sales"); + } + const events = response.data ?? []; + if (events.length === 0) { + throw new Error("Selected events not found."); + } + + const isPartnerMismatch = customer.linkId !== linkId; + + if (isPartnerMismatch) { + // Duplicate events with fresh IDs for the new customer/link, then create commissions for those new IDs. + await Promise.allSettled( + events.map((event: any) => { + const isSale = "amount" in event && Number(event.amount) > 0; + const newEventId = nanoid(16); + const ts = new Date(event.timestamp); + const recordPromise = isSale + ? recordSaleWithTimestamp({ + ...event, + event_id: newEventId, + customer_id: customer.id, + link_id: link.id, + }) + : recordLeadWithTimestamp({ + ...event, + event_id: newEventId, + customer_id: customer.id, + link_id: link.id, + }); + const commissionPromise = createPartnerCommission({ + event: isSale ? "sale" : "lead", + programId, + partnerId, + linkId, + eventId: newEventId, + customerId: customer.id, + amount: isSale ? Number(event.amount) : 0, + quantity: 1, + ...(isSale && { invoiceId, currency: "usd" }), + createdAt: ts, + user, + context: { + customer: { country: customer.country }, + ...(isSale && { sale: { productId } }), + }, + }); + const workflowPromise = executeWorkflows({ + trigger: isSale + ? WorkflowTrigger.saleRecorded + : WorkflowTrigger.leadRecorded, + programId, + partnerId, + }); + return Promise.allSettled([ + recordPromise, + commissionPromise, + workflowPromise, + ]); + }), + ); + } else { + // Reuse existing events: create commissions referencing the existing IDs, no event writes. + const hasSale = events.some( + (e: any) => "amount" in e && Number(e.amount) > 0, + ); + await Promise.allSettled( + events.map((event: any) => + createPartnerCommission({ + event: + "amount" in event && Number(event.amount) > 0 + ? "sale" + : "lead", + programId, + partnerId, + linkId, + eventId: event.event_id, + customerId, + amount: + "amount" in event && Number(event.amount) > 0 + ? Number(event.amount) + : 0, + quantity: 1, + ...(("amount" in event && Number(event.amount) > 0) && { + invoiceId, + currency: "usd", + }), + createdAt: new Date(event.timestamp), + user, + context: { + customer: { country: customer.country }, + ...(("amount" in event && Number(event.amount) > 0) && { + sale: { productId }, + }), + }, + }), + ), + ); + await executeWorkflows({ + trigger: hasSale + ? WorkflowTrigger.saleRecorded + : WorkflowTrigger.leadRecorded, + programId, + partnerId, + }); + } + return; + }
🧹 Nitpick comments (2)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
341-358: Send only accepted fields to TB record endpointsSpreading events from v2_events into recordLead/Sale may include columns not accepted by the ingestion endpoints. Pass the whitelisted fields (event_id, event_name, customer_id, link_id, amount, currency, timestamp, metadata, etc.).
I can generate a typed pick for the TB schemas if helpful.
371-390: Stats updates may be inconsistent for migrated customers
- isFirstConversion uses the (possibly cloned) customer object; if counters weren’t reset on the new record, first-conversion increments won’t fire.
- Sale increments are gated by saleAmount, which may be undefined for reuse/duplicate flows.
Would you like a follow-up patch to compute link/customer increments based on the processed events (lead vs sale) rather than saleAmount?
Also applies to: 394-414
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/actions/partners/create-manual-commission.ts(5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
apps/web/lib/api/create-id.ts (1)
createId(62-67)apps/web/lib/zod/schemas/analytics.ts (1)
eventsFilterTB(321-331)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
apps/web/lib/actions/partners/create-manual-commission.ts (4)
223-246: Write events/commissions to the target customer after a moveUse
targetCustomerIdfor both the event payload and the commission record.Apply this diff:
recordLeadWithTimestamp({ ...leadEvent, timestamp: new Date(finalLeadEventDate).toISOString(), + customer_id: targetCustomerId, }), @@ eventId: leadEventId, - customerId, + customerId: targetCustomerId,
255-301: Sale path still references the original customer; fix attributionEnsure sale event and commission target the correct customer after a move.
Apply this diff:
recordSaleWithTimestamp({ ...leadEvent, event_id: saleEventId, event_name: "Purchase", amount: saleAmount, - customer_id: customerId, + customer_id: targetCustomerId, payment_processor: "custom", currency: "usd", timestamp: saleDate.toISOString(), metadata: productId ? JSON.stringify({ productId }) : undefined, }), @@ eventId: saleEventId, - customerId, + customerId: targetCustomerId,
364-418: Stats update targets the wrong customer and may double-count
- Uses
customerId(old) after a move; should usetargetCustomerId.- Consider skipping manual stat bumps when merely reusing existing events (no duplicates), and scaling counts when duplicating multiple events.
Apply the ID fix; then consider gating/counting as noted.
// Update customer details / stats - (shouldUpdateCustomer || saleAmount) && + (shouldUpdateCustomer || saleAmount) && prisma.customer.update({ where: { - id: customerId, + id: targetCustomerId, },Optional follow-up (not shown as diff): wrap the whole waitUntil block behind a flag like
shouldBumpStats = !eventIds?.length || shouldDuplicateEventsand, if duplicating, increment byleadRows.length/saleRows.lengthrespectively.
86-101: Early reuse path for eventIds when not moving customersIf
eventIdsare provided butshouldCreateNewCustomeris false, they’re currently ignored. Implement a reuse path that creates commissions referencing the existing events (no duplication) and return early.Proposed insertion (outside the shown diff; place after link validation and invoice guard):
if (eventIds?.length && !shouldCreateNewCustomer) { // Fetch both types by IDs (same `pipe` approach as duplication), // then create commissions that reference those eventIds directly // (no Tinybird `record*` calls). Trigger workflows per type. return; }I can wire this with the same helper used in the duplication block; want a patch?
♻️ Duplicate comments (3)
apps/web/lib/actions/partners/create-manual-commission.ts (3)
307-314: Don’t infer event type from saleAmount
const eventType = !saleAmount ? "leads" : "sales"will misclassify valid events. Fetch both types by ID(s) and branch by actual rows (see prior diff).
132-156: Don't spread Prisma entities into create(); also capture and use a targetCustomerIdSpreading
...customercan copy read-only/unique fields (and stale values from before the update), causing constraint issues and incorrect state. Also, later writes still use the originalcustomerId. IntroducetargetCustomerIdand copy only safe fields.Apply this focused diff:
- if (shouldCreateNewCustomer) { - customer = await prisma.$transaction(async (tx) => { + let targetCustomerId = customer!.id; + if (shouldCreateNewCustomer) { + customer = await prisma.$transaction(async (tx) => { await tx.customer.update({ where: { id: customer!.id, }, data: { name: `${customer!.name} (old)`, externalId: null, stripeCustomerId: null, linkId: null, clickId: null, }, }); - - return await tx.customer.create({ - data: { - ...customer, - id: createId({ prefix: "cus_" }), - linkId: link.id, - projectId: workspace.id, - }, - }); - }); + const newCustomer = await tx.customer.create({ + data: { + id: createId({ prefix: "cus_" }), + name: customer!.name, + country: customer!.country, + linkId: link.id, + projectId: workspace.id, + }, + }); + return newCustomer; + }); + targetCustomerId = customer.id; }
303-362: Duplication flow: generate new IDs, create commissions, trigger workflows, and remove console.logCurrent code reuses
event.event_id(collision risk), doesn’t create commissions for duplicates, doesn’t trigger workflows, and logs to console. Also, inferringeventTypefromsaleAmountcan misclassify events.Apply this replacement to the duplication block:
- const shouldDuplicateEvents = - eventIds && eventIds.length > 0 && shouldCreateNewCustomer; + const shouldDuplicateEvents = + eventIds && eventIds.length > 0 && shouldCreateNewCustomer; @@ - if (shouldDuplicateEvents) { - const eventType = !saleAmount ? "leads" : "sales"; - - // Find the events by the eventIds - const { startDate, endDate } = getStartEndDates({ - interval: "all", - }); - - const pipe = tb.buildPipe({ - pipe: "v2_events", - parameters: eventsFilterTB, - data: z.any(), - }); - - const response = await pipe({ - workspaceId: workspace.id, - eventType, - sortBy: "timestamp", - offset: 0, - limit: 100, - start: startDate.toISOString().replace("T", " ").replace("Z", ""), - end: endDate.toISOString().replace("T", " ").replace("Z", ""), - eventIds, - }); - - const events = response.data; - - if (events.length === 0) { - throw new Error("Selected events not found."); - } - - console.log(events); - - // Duplicate the events for the new customer - if (eventType === "leads") { - await Promise.all( - events.map((event) => - recordLeadWithTimestamp({ - ...event, - customer_id: customer.id, - link_id: link.id, - }), - ), - ); - } else if (eventType === "sales") { - await Promise.all( - events.map((event) => - recordSaleWithTimestamp({ - ...event, - customer_id: customer.id, - link_id: link.id, - }), - ), - ); - } - } + if (shouldDuplicateEvents) { + const { startDate, endDate } = getStartEndDates({ interval: "all" }); + const pipe = tb.buildPipe({ + pipe: "v2_events", + parameters: eventsFilterTB, + data: z.any(), + }); + + const fetchEvents = async (eventType: "leads" | "sales") => { + const res = await pipe({ + workspaceId: workspace.id, + eventType, + sortBy: "timestamp", + offset: 0, + limit: eventIds.length, + start: startDate.toISOString().replace("T", " ").replace("Z", ""), + end: endDate.toISOString().replace("T", " ").replace("Z", ""), + eventIds, + }); + return res.data as any[]; + }; + + const [leadRows, saleRows] = await Promise.all([ + fetchEvents("leads"), + fetchEvents("sales"), + ]); + if (leadRows.length + saleRows.length === 0) { + throw new Error("Selected events not found."); + } + + // Duplicate leads + await Promise.allSettled( + leadRows.map(async (event) => { + const newId = nanoid(16); + await recordLeadWithTimestamp({ + ...event, + event_id: newId, + customer_id: customer.id, + link_id: link.id, + }); + await createPartnerCommission({ + event: "lead", + programId, + partnerId, + linkId, + eventId: newId, + customerId: customer.id, + amount: 0, + quantity: 1, + createdAt: new Date(event.timestamp), + user, + context: { customer: { country: customer.country } }, + }); + }), + ); + + // Duplicate sales + await Promise.allSettled( + saleRows.map(async (event) => { + const newId = nanoid(16); + await recordSaleWithTimestamp({ + ...event, + event_id: newId, + customer_id: customer.id, + link_id: link.id, + }); + await createPartnerCommission({ + event: "sale", + programId, + partnerId, + linkId, + eventId: newId, + customerId: customer.id, + amount: Number(event.amount) || 0, + quantity: 1, + invoiceId, + currency: (event.currency || "usd").toLowerCase(), + createdAt: new Date(event.timestamp), + user, + context: { + customer: { country: customer.country }, + sale: { productId }, + }, + }); + }), + ); + + await Promise.allSettled([ + leadRows.length + ? executeWorkflows({ + trigger: WorkflowTrigger.leadRecorded, + programId, + partnerId, + }) + : Promise.resolve(), + saleRows.length + ? executeWorkflows({ + trigger: WorkflowTrigger.saleRecorded, + programId, + partnerId, + }) + : Promise.resolve(), + ]); + return; + }
🧹 Nitpick comments (4)
apps/web/lib/actions/partners/create-manual-commission.ts (4)
321-330: Limit should reflect the number of eventIdsAvoid truncating results when more than 100 IDs are selected.
Apply this tiny change (included in the larger diff too):
- limit: 100, + limit: eventIds.length,
338-338: Remove debug logging
console.log(events)leaks potentially sensitive data and adds noise.Apply this deletion (already removed in the larger diff).
27-30: Resolve or remove TODOThe code now implements the partner-move + duplication flow; update/remove the TODO to prevent confusion.
158-171: Existing-lead reuse may be wrong after partner move
getLeadEvent({ customerId })queries the old customer. If you intend to skip dummy creation wheneventIdsare provided (either reuse or duplicate), short-circuit before this block. Otherwise, query usingtargetCustomerIdafter the move.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/actions/partners/create-manual-commission.ts(5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
apps/web/lib/api/create-id.ts (1)
createId(62-67)apps/web/lib/zod/schemas/analytics.ts (1)
eventsFilterTB(321-331)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)
177-193: Do not send eventIds when user chose “Create from scratch”.Currently eventIds are always sent, which duplicates events even when not intended.
Apply:
- // Get selected event IDs (exclude the excluded ones) - const selectedEventIds = existingEvents + // Get selected event IDs (exclude the excluded ones) + const selectedEventIds = existingEvents .filter( (event) => event.eventId && !excludedEventIds.includes(event.eventId), ) .map((event) => event.eventId!); await executeAsync({ ...data, workspaceId, date, amount: data.amount ? data.amount * 100 : null, saleAmount: data.saleAmount ? data.saleAmount * 100 : null, saleEventDate, leadEventDate, - eventIds: selectedEventIds, + eventIds: useExistingEvents ? selectedEventIds : null, });
200-218: Fix submit gating for “Use existing events”.Sale submit is disabled without saleAmount even when existing events are selected; lead submit doesn’t guard zero selection.
- const shouldDisableSubmit = useMemo(() => { + const shouldDisableSubmit = useMemo(() => { + const hasSelectedExisting = + useExistingEvents && + existingEvents.some((e) => !excludedEventIds.includes(e.eventId || "")); if (!partnerId) { return true; } if (commissionType === "custom") { return !amount; } if (commissionType === "sale") { - return !linkId || !customerId || !saleAmount; + return !linkId || !customerId || (!hasSelectedExisting && !saleAmount); } if (commissionType === "lead") { - return !linkId || !customerId; + return !linkId || !customerId || (useExistingEvents && !hasSelectedExisting); } return false; - }, [commissionType, partnerId, linkId, customerId, saleAmount, amount]); + }, [ + commissionType, + partnerId, + linkId, + customerId, + saleAmount, + amount, + useExistingEvents, + existingEvents, + excludedEventIds, + ]);
♻️ Duplicate comments (6)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (6)
141-148: Remove non-existent “sale” accordion key.No item with value="sale" exists; referencing it breaks auto-open behavior.
- } else if (commissionType === "sale") { - setOpenAccordions([...baseValues, "customer", "sale"]); + } else if (commissionType === "sale") { + setOpenAccordions([...baseValues, "customer"]);
225-263: Harden events fetch: response.ok check, cancellation, and full deps.Prevents UI crashes on HTTP errors, stale updates, and stale workspace/folder data. Also guard missing defaultFolderId.
useEffect(() => { - if ( + if ( !customerId || !commissionType || !workspaceId || - commissionType === "custom" + commissionType === "custom" || + !program?.defaultFolderId ) { setExistingEvents([]); setExistingEventsLoading(false); return; } - const fetchEvents = async () => { + const controller = new AbortController(); + let cancelled = false; + + const fetchEvents = async () => { setExistingEventsLoading(true); try { const searchParams = new URLSearchParams({ workspaceId, customerId, - folderId: program?.defaultFolderId!, + folderId: program.defaultFolderId, event: `${commissionType}s`, }); - const response = await fetch(`/api/events?${searchParams.toString()}`, { - method: "GET", - }); - - setExistingEvents(await response.json()); + const response = await fetch(`/api/events?${searchParams.toString()}`, { + method: "GET", + signal: controller.signal, + }); + if (!response.ok) throw new Error(`Failed to fetch events: ${response.status}`); + const json = await response.json(); + if (!cancelled) setExistingEvents(json); } catch (error) { - setExistingEvents([]); + if (!cancelled) setExistingEvents([]); } finally { - setExistingEventsLoading(false); + if (!cancelled) setExistingEventsLoading(false); } }; fetchEvents(); -}, [customerId, commissionType]); + return () => { + cancelled = true; + controller.abort(); + }; +}, [customerId, commissionType, workspaceId, program?.defaultFolderId]);
570-609: Lead date toggle is unreachable; keep switch visible, animate only input.Current structure hides the Switch when collapsed.
-<AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - style={{ - height: hasCustomLeadEventDate ? "auto" : "0px", - overflow: "hidden", - }} -> - <div className="flex flex-col gap-6"> - <div className="flex items-center gap-4"> - <Switch - fn={setHasCustomLeadEventDate} - checked={hasCustomLeadEventDate} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex flex-col gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Set a custom lead event date - </h3> - </div> - </div> - - {hasCustomLeadEventDate && ( - <div className="p-px"> - <SmartDateTimePicker - value={leadEventDate} - onChange={(date) => { - setValue("leadEventDate", date, { - shouldDirty: true, - }); - }} - label="Lead event date" - placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"' - /> - </div> - )} - </div> -</AnimatedSizeContainer> +<div className="flex items-center gap-4"> + <Switch + fn={setHasCustomLeadEventDate} + checked={hasCustomLeadEventDate} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex flex-col gap-1"> + <h3 className="text-sm font-medium text-neutral-700"> + Set a custom lead event date + </h3> + </div> +</div> +<AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ height: hasCustomLeadEventDate ? "auto" : "0px", overflow: "hidden" }} +> + {hasCustomLeadEventDate && ( + <div className="p-px"> + <SmartDateTimePicker + value={leadEventDate} + onChange={(date) => { + setValue("leadEventDate", date, { shouldDirty: true }); + }} + label="Lead event date" + placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"' + /> + </div> + )} +</AnimatedSizeContainer>
610-653: Lead name toggle has same issue; keep switch visible.-<AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - style={{ - height: hasCustomLeadEventName ? "auto" : "0px", - overflow: "hidden", - }} -> - <div className="flex flex-col gap-6"> - <div className="flex items-center gap-4"> - <Switch - fn={setHasCustomLeadEventName} - checked={hasCustomLeadEventName} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex flex-col gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Set a custom lead event name - </h3> - </div> - </div> - - {hasCustomLeadEventName && ( - <div className="p-px"> - <input - type="text" - className={cn( - "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", - errors.leadEventName && - "border-red-600 focus:border-red-500 focus:ring-red-600", - )} - {...register("leadEventName", { - setValueAs: (value) => - value === "" ? null : value, - })} - placeholder="Enter lead event name" - /> - </div> - )} - </div> -</AnimatedSizeContainer> +<div className="flex items-center gap-4"> + <Switch + fn={setHasCustomLeadEventName} + checked={hasCustomLeadEventName} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex flex-col gap-1"> + <h3 className="text-sm font-medium text-neutral-700"> + Set a custom lead event name + </h3> + </div> +</div> +<AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ height: hasCustomLeadEventName ? "auto" : "0px", overflow: "hidden" }} +> + {hasCustomLeadEventName && ( + <div className="p-px"> + <input + type="text" + className={cn( + "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", + errors.leadEventName && + "border-red-600 focus:border-red-500 focus:ring-red-600", + )} + {...register("leadEventName", { + setValueAs: (value) => (value === "" ? null : value), + })} + placeholder="Enter lead event name" + /> + </div> + )} +</AnimatedSizeContainer>
719-775: Invoice ID toggle is unreachable; keep switch visible and animate only input.-<AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - className={!hasInvoiceId ? "hidden" : ""} - style={{ - display: !hasInvoiceId ? "none" : "block", - }} -> - <div className="flex items-center gap-4"> - <Switch - fn={setHasInvoiceId} - checked={hasInvoiceId} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Add{" "} - </h3> - <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> - invoiceID - </span> - </div> - </div> - - {hasInvoiceId && ( +<div className="flex items-center gap-4"> + <Switch + fn={setHasInvoiceId} + checked={hasInvoiceId} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex gap-1"> + <h3 className="text-sm font-medium text-neutral-700">Add</h3> + <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> + invoiceID + </span> + </div> +</div> +<AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ height: hasInvoiceId ? "auto" : "0px", overflow: "hidden" }} +> + {hasInvoiceId && ( <div className="mt-4"> <label htmlFor="invoiceId" className="flex items-center space-x-2" > <h2 className="text-sm font-medium text-neutral-900"> Invoice ID </h2> </label> <div className="mt-2 p-px"> <input type="text" id="invoiceId" className={cn( "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", errors.invoiceId && "border-red-600 focus:border-red-500 focus:ring-red-600", )} {...register("invoiceId", { required: hasInvoiceId, setValueAs: (value) => value === "" ? null : value, })} placeholder="Enter invoice ID" /> </div> </div> )} -</AnimatedSizeContainer> +</AnimatedSizeContainer>
776-831: Product ID toggle is unreachable; mirror the invoice fix.-<AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - className={!hasProductId ? "hidden" : ""} - style={{ - display: !hasProductId ? "none" : "block", - }} -> - <div className="flex items-center gap-4"> - <Switch - fn={setHasProductId} - checked={hasProductId} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Add{" "} - </h3> - <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> - productID - </span> - </div> - </div> - - {hasProductId && ( +<div className="flex items-center gap-4"> + <Switch + fn={setHasProductId} + checked={hasProductId} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex gap-1"> + <h3 className="text-sm font-medium text-neutral-700">Add</h3> + <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> + productID + </span> + </div> +</div> +<AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ height: hasProductId ? "auto" : "0px", overflow: "hidden" }} +> + {hasProductId && ( <div className="mt-4"> <label htmlFor="productId" className="flex items-center space-x-2" > <h2 className="text-sm font-medium text-neutral-900"> Product ID </h2> </label> <div className="mt-2 p-px"> <input type="text" id="productId" className={cn( "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", errors.productId && "border-red-600 focus:border-red-500 focus:ring-red-600", )} {...register("productId", { required: hasProductId, setValueAs: (value) => value === "" ? null : value, })} placeholder="Enter product ID" /> </div> </div> )} -</AnimatedSizeContainer> +</AnimatedSizeContainer>
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)
874-941: Provide stable row ids to the table.Improves performance and selection consistency.
- const table = useTable({ + const table = useTable({ data: events || [], columns: [ { id: "date", @@ ], + getRowId: (row) => row.eventId || String(row.timestamp), thClassName: (id) =>
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx(10 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)
apps/web/lib/swr/use-rewards.ts (1)
useRewards(6-25)packages/ui/src/table/table.tsx (2)
useTable(48-240)Table(338-661)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(5-16)
⏰ 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). (2)
- GitHub Check: build
- GitHub Check: Vade Review
🔇 Additional comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)
44-48: API event shape matches CustomerEvent — no change required.getEvents maps evt.event_id → eventId, sets timestamp (serialized to ISO), and returns both nested sale.amount and a top-level saleAmount (deprecated), so the component's CustomerEvent { eventId, timestamp, saleAmount? } matches the API response. See apps/web/lib/analytics/get-events.ts and apps/web/lib/zod/schemas/sales.ts.
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
♻️ Duplicate comments (5)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (5)
141-147: Remove non-existent "sale" accordion keyThe component doesn’t render a
value="sale"item; opening it has no effect.Apply this diff:
- } else if (commissionType === "sale") { - setOpenAccordions([...baseValues, "customer", "sale"]); + } else if (commissionType === "sale") { + setOpenAccordions([...baseValues, "customer"]);
62-62: Fix events fetch effect: missing deps, unsafe!, no abort, no response.ok checkPrevent bad
folderId=undefined, stale updates, and swallowing HTTP errors.Apply this diff:
- const { program } = useProgram(); + const { program, loading: programLoading } = useProgram(); @@ - useEffect(() => { - if ( - !customerId || - !commissionType || - !workspaceId || - commissionType === "custom" - ) { - setExistingEvents([]); - setExistingEventsLoading(false); - return; - } - - const fetchEvents = async () => { - setExistingEventsLoading(true); - - try { - const searchParams = new URLSearchParams({ - workspaceId, - customerId, - folderId: program?.defaultFolderId!, - event: `${commissionType}s`, - }); - - const response = await fetch(`/api/events?${searchParams.toString()}`, { - method: "GET", - }); - - setExistingEvents(await response.json()); - } catch (error) { - setExistingEvents([]); - } finally { - setExistingEventsLoading(false); - } - }; - - fetchEvents(); - }, [customerId, commissionType]); + useEffect(() => { + if ( + !customerId || + !commissionType || + !workspaceId || + commissionType === "custom" || + programLoading || + !program?.defaultFolderId + ) { + setExistingEvents([]); + setExistingEventsLoading(false); + return; + } + + const controller = new AbortController(); + const { signal } = controller; + let cancelled = false; + + const fetchEvents = async () => { + setExistingEventsLoading(true); + try { + const searchParams = new URLSearchParams({ + workspaceId, + customerId, + folderId: program.defaultFolderId, + event: `${commissionType}s`, + }); + const response = await fetch(`/api/events?${searchParams.toString()}`, { + method: "GET", + signal, + }); + if (!response.ok) { + throw new Error(`Failed to fetch events: ${response.status}`); + } + const json = await response.json(); + if (!cancelled) setExistingEvents(Array.isArray(json) ? json : []); + } catch (err: any) { + if (err?.name === "AbortError") return; + if (!cancelled) setExistingEvents([]); + } finally { + if (!cancelled) setExistingEventsLoading(false); + } + }; + + fetchEvents(); + return () => { + cancelled = true; + controller.abort(); + }; + }, [ + customerId, + commissionType, + workspaceId, + programLoading, + program?.defaultFolderId, + ]);Also applies to: 232-269
572-660: Lead toggles are unreachable (switch hidden inside collapsed container)Keep the Switch visible; animate only the input block.
Apply this diff:
- <> - <AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - style={{ - height: hasCustomLeadEventDate ? "auto" : "0px", - overflow: "hidden", - }} - > - <div className="flex flex-col gap-6"> - <div className="flex items-center gap-4"> - <Switch - fn={setHasCustomLeadEventDate} - checked={hasCustomLeadEventDate} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex flex-col gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Set a custom lead event date - </h3> - </div> - </div> - - {hasCustomLeadEventDate && ( - <div className="p-px"> - <SmartDateTimePicker - value={leadEventDate} - onChange={(date) => { - setValue("leadEventDate", date, { - shouldDirty: true, - }); - }} - label="Lead event date" - placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"' - /> - </div> - )} - </div> - </AnimatedSizeContainer> + <> + <div className="flex items-center gap-4"> + <Switch + fn={setHasCustomLeadEventDate} + checked={hasCustomLeadEventDate} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex flex-col gap-1"> + <h3 className="text-sm font-medium text-neutral-700"> + Set a custom lead event date + </h3> + </div> + </div> + <AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ + height: hasCustomLeadEventDate ? "auto" : "0px", + overflow: "hidden", + }} + > + {hasCustomLeadEventDate && ( + <div className="p-px"> + <SmartDateTimePicker + value={leadEventDate} + onChange={(date) => { + setValue("leadEventDate", date, { + shouldDirty: true, + }); + }} + label="Lead event date" + placeholder='E.g. "2024-03-01", "Last Thursday", "2 hours ago"' + /> + </div> + )} + </AnimatedSizeContainer> @@ - <AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - style={{ - height: hasCustomLeadEventName ? "auto" : "0px", - overflow: "hidden", - }} - > - <div className="flex flex-col gap-6"> - <div className="flex items-center gap-4"> - <Switch - fn={setHasCustomLeadEventName} - checked={hasCustomLeadEventName} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex flex-col gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Set a custom lead event name - </h3> - </div> - </div> - - {hasCustomLeadEventName && ( - <div className="p-px"> - <input - type="text" - className={cn( - "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", - errors.leadEventName && - "border-red-600 focus:border-red-500 focus:ring-red-600", - )} - {...register("leadEventName", { - setValueAs: (value) => - value === "" ? null : value, - })} - placeholder="Enter lead event name" - /> - </div> - )} - </div> - </AnimatedSizeContainer> + <div className="flex items-center gap-4"> + <Switch + fn={setHasCustomLeadEventName} + checked={hasCustomLeadEventName} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex flex-col gap-1"> + <h3 className="text-sm font-medium text-neutral-700"> + Set a custom lead event name + </h3> + </div> + </div> + <AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ + height: hasCustomLeadEventName ? "auto" : "0px", + overflow: "hidden", + }} + > + {hasCustomLeadEventName && ( + <div className="p-px"> + <input + type="text" + className={cn( + "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", + errors.leadEventName && + "border-red-600 focus:border-red-500 focus:ring-red-600", + )} + {...register("leadEventName", { + setValueAs: (value) => + value === "" ? null : value, + })} + placeholder="Enter lead event name" + /> + </div> + )} + </AnimatedSizeContainer>
726-782: Invoice ID toggle unreachable (switch hidden when off)Move the Switch out; animate only the input.
Apply this diff:
- <AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - className={!hasInvoiceId ? "hidden" : ""} - style={{ - display: !hasInvoiceId ? "none" : "block", - }} - > - <div className="flex items-center gap-4"> - <Switch - fn={setHasInvoiceId} - checked={hasInvoiceId} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Add{" "} - </h3> - <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> - invoiceID - </span> - </div> - </div> - - {hasInvoiceId && ( - <div className="mt-4"> + <div className="flex items-center gap-4"> + <Switch + fn={setHasInvoiceId} + checked={hasInvoiceId} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex gap-1"> + <h3 className="text-sm font-medium text-neutral-700"> + Add + </h3> + <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> + invoiceID + </span> + </div> + </div> + <AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ + height: hasInvoiceId ? "auto" : "0px", + overflow: "hidden", + }} + > + {hasInvoiceId && ( + <div className="mt-4"> <label htmlFor="invoiceId" className="flex items-center space-x-2" > <h2 className="text-sm font-medium text-neutral-900"> Invoice ID </h2> </label> <div className="mt-2 p-px"> <input type="text" id="invoiceId" className={cn( "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", errors.invoiceId && "border-red-600 focus:border-red-500 focus:ring-red-600", )} {...register("invoiceId", { required: hasInvoiceId, setValueAs: (value) => value === "" ? null : value, })} placeholder="Enter invoice ID" /> </div> </div> )} - </AnimatedSizeContainer> + </AnimatedSizeContainer>
783-838: Product ID toggle unreachable (same issue as Invoice ID)Mirror the fix for Product ID.
Apply this diff:
- <AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - className={!hasProductId ? "hidden" : ""} - style={{ - display: !hasProductId ? "none" : "block", - }} - > - <div className="flex items-center gap-4"> - <Switch - fn={setHasProductId} - checked={hasProductId} - trackDimensions="w-8 h-4" - thumbDimensions="w-3 h-3" - thumbTranslate="translate-x-4" - /> - <div className="flex gap-1"> - <h3 className="text-sm font-medium text-neutral-700"> - Add{" "} - </h3> - <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> - productID - </span> - </div> - </div> - - {hasProductId && ( - <div className="mt-4"> + <div className="flex items-center gap-4"> + <Switch + fn={setHasProductId} + checked={hasProductId} + trackDimensions="w-8 h-4" + thumbDimensions="w-3 h-3" + thumbTranslate="translate-x-4" + /> + <div className="flex gap-1"> + <h3 className="text-sm font-medium text-neutral-700"> + Add + </h3> + <span className="rounded-md border border-neutral-200 bg-neutral-100 px-1 py-0.5 text-xs"> + productID + </span> + </div> + </div> + <AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + style={{ + height: hasProductId ? "auto" : "0px", + overflow: "hidden", + }} + > + {hasProductId && ( + <div className="mt-4"> <label htmlFor="productId" className="flex items-center space-x-2" > <h2 className="text-sm font-medium text-neutral-900"> Product ID </h2> </label> <div className="mt-2 p-px"> <input type="text" id="productId" className={cn( "block w-full rounded-md border-neutral-300 px-3 py-2 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:text-sm", errors.productId && "border-red-600 focus:border-red-500 focus:ring-red-600", )} {...register("productId", { required: hasProductId, setValueAs: (value) => value === "" ? null : value, })} placeholder="Enter product ID" /> </div> </div> )} - </AnimatedSizeContainer> + </AnimatedSizeContainer>
🧹 Nitpick comments (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (1)
874-951: Stabilize rows with getRowId for eventIdBetter table behavior on updates; avoids index-based keys.
Apply this diff:
- const table = useTable({ + const table = useTable({ data: events || [], + getRowId: (row) => row.eventId,
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (3)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx(10 hunks)apps/web/lib/actions/partners/create-manual-commission.ts(5 hunks)apps/web/lib/zod/schemas/commissions.ts(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/web/lib/zod/schemas/commissions.ts
- apps/web/lib/actions/partners/create-manual-commission.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (3)
apps/web/lib/swr/use-rewards.ts (1)
useRewards(6-25)packages/ui/src/table/table.tsx (2)
useTable(48-240)Table(338-661)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(5-16)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx (2)
44-48: Confirm CustomerEvent payload shape and unitsVerify
/api/eventsreturnseventId,timestamp(ISO), andsaleAmountin cents. If not, map the response before setting state to avoid runtime mismatches.
130-136: Exclude/Include toggle logic looks goodIdempotent toggle with minimal state churn. LGTM.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (7)
apps/web/lib/actions/partners/create-manual-commission.ts (7)
165-169: Remove debug console logs (PII risk, noisy in prod logs)These log raw customer/event payloads. Prefer structured logging behind a debug flag if needed.
- console.log("Found existing click event: ", existingClickEvent); - console.log("Found existing lead event: ", existingLeadEvent); - console.log("Found existing sale events: ", existingSaleEvents); @@ - console.log( - `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `, - duplicatedCustomer, - ); @@ - console.log("recorded events in Tinybird: ", res); @@ - console.log( - "Final commissions to transfer event ids: ", - finalCommissionsToTransferEventIds, - );Also applies to: 169-173, 315-318, 416-417, 425-428
328-339: Preserve country on generated click eventsWithout
country, Zod defaults to "Unknown", which later overwrites customer country.const generatedClickEvent = recordClickZodSchema.parse({ timestamp: clickTimestamp.toISOString(), identity_hash: customer.externalId || customer.id, click_id: clickId, link_id: link.id, url: link.url, ip: "127.0.0.1", + country: customer.country || "Unknown", continent: customer.country ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA" : "NA", });
437-447: Use correct customer context for first-conversion calc after duplicationPassing the original customer inflates conversions. Use duplicated stats.
- ...(isFirstConversion({ - customer, - linkId, - }) && { + ...(isFirstConversion({ + customer: useExistingEvents + ? { sales: totalSales, linkId: link.id } + : customer, + linkId, + }) && {
430-471: Update customer stats in non-reuse sale path for consistencyWhen creating new sale events (
!useExistingEvents),customer.sales/saleAmountaren’t incremented.const updatedRes = await Promise.all([ // update link stats prisma.link.update({ @@ }), + // keep customer stats in sync for new sale events + !useExistingEvents && + saleAmount && + prisma.customer.update({ + where: { id: customer.id }, + data: { + sales: { increment: 1 }, + saleAmount: { increment: saleAmount }, + }, + }), // update the commissions - finalCommissionsToTransferEventIds.length > 0 && - prisma.commission.updateMany({ - where: { - eventId: { - in: finalCommissionsToTransferEventIds, - }, - }, - data: { - eventId: null, - invoiceId: null, - }, - }), + /* provenance-preserving transfer handled elsewhere */ ]);
175-183: Fix crash when no existing click; also ensure a click is recorded to maintain event chainSpreading
existingClickEventwhen it’s undefined throws, and later you rely onclickEventDataregardless. Build from a safe base, infer a timestamp/identity, and always record the click.- const clickEventData = recordClickZodSchema.parse({ - ...existingClickEvent, - click_id: nanoid(16), // create new clickId - link_id: link.id, // set to new link.id, - }); - - if (existingClickEvent) { - tbEventsToRecord.push(recordClickZod(clickEventData)); - } + const inferredClickTs = + (existingClickEvent?.timestamp as string | undefined) ?? + (existingLeadEvent?.timestamp as string | undefined) ?? + (existingSaleEvents[0]?.timestamp as string | undefined) ?? + new Date().toISOString(); + + const clickEventData = recordClickZodSchema.parse({ + ...(existingClickEvent ?? {}), + timestamp: inferredClickTs, + identity_hash: + existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id, + click_id: nanoid(16), + link_id: link.id, + }); + + tbEventsToRecord.push(recordClickZod(clickEventData));
457-469: Do not nullify eventId/invoiceId — breaks dedup, reporting, and auditabilityNulling provenance fields severs links used across exports, payouts, and idempotency. Prefer updating attribution (partner/link/customer) or recording a transfer record; don’t destroy keys.
- finalCommissionsToTransferEventIds.length > 0 && - prisma.commission.updateMany({ - where: { - eventId: { - in: finalCommissionsToTransferEventIds, - }, - }, - data: { - eventId: null, - invoiceId: null, - }, - }), + // Intentionally avoid nullifying provenance fields; handle attribution transfer explicitly elsewhere.
299-312: Do not spread Prisma entity into create(); copy safe fields and null external/unique fieldsSpreading
...customerrisks unique constraint violations (e.g., external/Stripe IDs), wrong timestamps, and leaking old attribution.- return await tx.customer.create({ - data: { - ...customer, - id: duplicateCustomerId, - linkId: link.id, - clickId: clickEventData.click_id, - clickedAt: new Date(clickEventData.timestamp), - country: clickEventData.country, - ...(recordSaleEvents && { - sales: totalSales, - saleAmount: totalSaleAmount, - }), - }, - }); + return await tx.customer.create({ + data: { + id: duplicateCustomerId, + name: customer.name, + email: customer.email, + avatar: customer.avatar, + // reset external/unique fields + externalId: null, + stripeCustomerId: null, + // preserve original country when available + country: customer.country ?? clickEventData.country, + linkId: link.id, + clickId: clickEventData.click_id, + clickedAt: new Date( + (existingLeadEvent?.timestamp as string | undefined) ?? + (existingSaleEvents[0]?.timestamp as string | undefined) ?? + clickEventData.timestamp + ), + ...(recordSaleEvents + ? { sales: totalSales, saleAmount: totalSaleAmount } + : {}), + }, + });
🧹 Nitpick comments (2)
apps/web/lib/actions/partners/create-manual-commission.ts (2)
373-387: Guard sale-event creation by commissionType to avoid stray salesCurrently a “lead” commission with saleAmount records a sale event but no commission. Gate by type.
- if (saleAmount) { + if (commissionType === "sale" && saleAmount) {
415-417: Handle Tinybird ingest failures explicitlyAt least count rejects and surface an actionable error/log, not raw payloads.
- const res = await Promise.allSettled(tbEventsToRecord); - console.log("recorded events in Tinybird: ", res); + const res = await Promise.allSettled(tbEventsToRecord); + const failures = res.filter((r) => r.status === "rejected"); + if (failures.length > 0) { + // consider bubbling or structured logging + throw new Error(`Failed to record ${failures.length} event(s) to Tinybird`); + }
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/actions/partners/create-manual-commission.ts(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (10)
apps/web/lib/zod/schemas/leads.ts (1)
leadEventSchemaTB(98-107)apps/web/lib/zod/schemas/sales.ts (1)
saleEventSchemaTB(120-137)apps/web/lib/zod/schemas/commissions.ts (1)
createCommissionSchema(131-153)apps/web/lib/tinybird/get-customer-events-tb.ts (1)
getCustomerEventsTB(10-21)apps/web/lib/tinybird/record-click-zod.ts (2)
recordClickZodSchema(4-34)recordClickZod(36-40)apps/web/lib/api/create-id.ts (1)
createId(62-67)apps/web/lib/partners/create-partner-commission.ts (1)
createPartnerCommission(25-326)packages/utils/src/constants/continents.ts (1)
COUNTRIES_TO_CONTINENTS(13-264)apps/web/lib/analytics/is-first-conversion.ts (1)
isFirstConversion(3-23)apps/web/lib/api/workflows/execute-workflows.ts (1)
executeWorkflows(17-122)
⏰ 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). (2)
- GitHub Check: build
- GitHub Check: Vade Review
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
Show resolved
Hide resolved
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
♻️ Duplicate comments (4)
apps/web/lib/actions/partners/create-manual-commission.ts (4)
439-447: Compute first‑conversion against the correct customer in reuse flow.isFirstConversion should use the duplicated customer’s stats, not the original.
Apply:
- ...(isFirstConversion({ - customer, - linkId, - }) && { + ...(isFirstConversion({ + customer: useExistingEvents + ? { sales: totalSales, linkId: link.id } + : customer, + linkId, + }) && {
178-187: Fix: build clickEventData safely when no existing click; also ensure identity/timestamp.Spreading possibly-undefined existingClickEvent is okay, but timestamp defaults to "" which later produces Invalid Date for clickedAt.
Apply:
+ const inferredClickTs = + (existingClickEvent?.timestamp as string | undefined) ?? + (existingLeadEvent?.timestamp as string | undefined) ?? + (existingSaleEvents[0]?.timestamp as string | undefined) ?? + new Date().toISOString(); const clickEventData = recordClickZodSchema.parse({ - ...existingClickEvent, - ...newClickAttributes, + ...(existingClickEvent ?? {}), + timestamp: inferredClickTs, + identity_hash: + existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id, + ...newClickAttributes, });
460-471: Provenance risk: nullifying eventId/invoiceId; also scope the update by programId.Setting eventId/invoiceId to null severs audit/dedup trails. At minimum, constrain by programId; ideally, preserve originals in audit metadata.
Apply scoping fix:
- prisma.commission.updateMany({ - where: { - eventId: { - in: finalCommissionsToTransferEventIds, - }, - }, + prisma.commission.updateMany({ + where: { + programId, + eventId: { in: finalCommissionsToTransferEventIds }, + }, data: { eventId: null, invoiceId: null, }, }),Recommendation: record an explicit audit entry mapping old→new event/invoice IDs before nullifying. I can wire that if desired.
291-318: Critical: don’t spread a Prisma entity into create(); copy safe fields and fix clickedAt fallback.Spreading ...customer risks unique/timestamp/counter fields being reinserted and sets clickedAt from possibly invalid timestamp.
Apply:
- return await tx.customer.create({ - data: { - ...customer, - id: duplicateCustomerId, - linkId: link.id, - clickId: clickEventData.click_id, - clickedAt: new Date(clickEventData.timestamp), - country: clickEventData.country, - ...(recordSaleEvents && { - sales: totalSales, - saleAmount: totalSaleAmount, - }), - }, - }); + return await tx.customer.create({ + data: { + id: duplicateCustomerId, + projectId: workspace.id, + name: customer.name ?? null, + externalId: customer.externalId ?? null, + country: clickEventData.country, + linkId: link.id, + clickId: clickEventData.click_id, + clickedAt: new Date( + (existingLeadEvent?.timestamp as string | undefined) ?? + (existingSaleEvents[0]?.timestamp as string | undefined) ?? + clickEventData.timestamp + ), + ...(recordSaleEvents && { + sales: totalSales, + saleAmount: totalSaleAmount, + }), + }, + });
🧹 Nitpick comments (4)
apps/web/lib/partners/create-partner-commission.ts (1)
165-176: Use event timestamp (createdAt) for max‑duration checks, not “now”.Backfills can be incorrectly rejected/accepted when using the current time. Base differenceInMonths on the event’s createdAt when provided.
Apply:
- const monthsDifference = differenceInMonths( - new Date(), - firstCommission.createdAt, - ); + const referenceDate = createdAt ?? new Date(); + const monthsDifference = differenceInMonths( + referenceDate, + firstCommission.createdAt, + );apps/web/lib/actions/partners/create-manual-commission.ts (3)
334-345: Include country on generated click to preserve customer geo.Without country, defaults can backfill “Unknown” and desync continent/country.
Apply:
const generatedClickEvent = recordClickZodSchema.parse({ timestamp: clickTimestamp.toISOString(), identity_hash: customer.externalId || customer.id, click_id: clickId, link_id: link.id, url: link.url, ip: "127.0.0.1", + country: customer.country || "Unknown", continent: customer.country ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA" : "NA", });
165-176: Remove debug console logs (noise + PII risk).These print event/customer payloads into server logs. Use structured logging with redaction if needed.
Apply deletions across the selected lines.
Also applies to: 187-191, 251-253, 321-324, 417-419, 427-430, 474-477, 479-480
432-456: Non‑reuse path: update customer sales stats when creating sale events.To keep stats consistent with reuse path, increment customer.sales and saleAmount when saleAmount is provided.
Apply:
const updatedRes = await Promise.all([ // update link stats prisma.link.update({ where: { id: link.id, }, data: { ...(isFirstConversion({ customer: useExistingEvents ? { sales: totalSales, linkId: link.id } : customer, linkId, }) && { leads: { increment: 1, }, conversions: { increment: 1, }, }), sales: { increment: saleAmount ? 1 : totalSales, }, saleAmount: { increment: saleAmount ?? totalSaleAmount, }, }, }), + // keep customer stats in sync for non-reuse sales + !useExistingEvents && + saleAmount && + prisma.customer.update({ + where: { id: customer.id }, + data: { + sales: { increment: 1 }, + saleAmount: { increment: saleAmount }, + }, + }),
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/web/lib/actions/partners/create-manual-commission.ts(3 hunks)apps/web/lib/partners/create-partner-commission.ts(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
apps/web/lib/partners/create-partner-commission.ts (3)
packages/prisma/client.ts (1)
CommissionType(8-8)apps/web/lib/types.ts (1)
RewardContext(524-524)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(5-16)
apps/web/lib/actions/partners/create-manual-commission.ts (10)
apps/web/lib/zod/schemas/leads.ts (1)
leadEventSchemaTB(98-107)apps/web/lib/zod/schemas/sales.ts (1)
saleEventSchemaTB(120-137)apps/web/lib/zod/schemas/commissions.ts (1)
createCommissionSchema(131-153)apps/web/lib/partners/create-partner-commission.ts (1)
CreatePartnerCommissionProps(25-41)apps/web/lib/tinybird/get-customer-events-tb.ts (1)
getCustomerEventsTB(10-21)apps/web/lib/tinybird/record-click-zod.ts (2)
recordClickZodSchema(4-34)recordClickZod(36-40)apps/web/lib/api/create-id.ts (1)
createId(62-67)packages/utils/src/constants/continents.ts (1)
COUNTRIES_TO_CONTINENTS(13-264)apps/web/lib/analytics/is-first-conversion.ts (1)
isFirstConversion(3-23)apps/web/lib/api/workflows/execute-workflows.ts (1)
executeWorkflows(17-122)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)
25-41: Good: public input type is clear and future‑proof.Exporting CreatePartnerCommissionProps improves API clarity and discoverability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)
229-229: Guard currencyFormatter and redact payload to avoid crash and PII in logsPassing
{ currency: undefined }overrides the default USD and makes Intl.NumberFormat throw; also avoid logging the full commission object.- console.log( - `Created a ${event} commission ${commission.id} (${currencyFormatter(commission.earnings / 100, { currency: commission.currency })}) for ${partnerId}: ${JSON.stringify(commission)}`, - ); + console.log( + `Created a ${event} commission ${commission.id} (${ + currencyFormatter( + commission.earnings / 100, + commission.currency ? { currency: commission.currency } : undefined, + ) + }) for ${partnerId}`, + { commissionId: commission.id, partnerId, programId }, + );
🧹 Nitpick comments (2)
apps/web/lib/partners/create-partner-commission.ts (2)
25-41: Encode per-event invariants with a discriminated unionThis prevents invalid combinations (e.g., sale without amount/currency) at compile time.
-export type CreatePartnerCommissionProps = { - event: CommissionType; - partnerId: string; - programId: string; - linkId?: string; - customerId?: string; - eventId?: string; - invoiceId?: string | null; - amount?: number; - quantity: number; - currency?: string; - description?: string | null; - createdAt?: Date; - user?: Session["user"]; // user who created the manual commission - context?: RewardContext; - skipWorkflow?: boolean; -}; +type BaseCommissionProps = { + partnerId: string; + programId: string; + linkId?: string; + customerId?: string; + eventId?: string; + invoiceId?: string | null; + description?: string | null; + createdAt?: Date; + user?: Session["user"]; + context?: RewardContext; + skipWorkflow?: boolean; +}; + +export type CreatePartnerCommissionProps = + | (BaseCommissionProps & { + event: "custom"; + amount: number; + quantity: number; + currency?: string; + }) + | (BaseCommissionProps & { + event: "click"; + quantity: number; + }) + | (BaseCommissionProps & { + event: "lead"; + quantity: number; + }) + | (BaseCommissionProps & { + event: "sale"; + amount: number; + quantity: number; + currency: string; + invoiceId?: string | null; + });
211-213: Trim and null-normalize IDs to handle whitespace-only inputPrevents storing " " instead of null.
- eventId: eventId || null, // empty string should convert to null - invoiceId: invoiceId || null, // empty string should convert to null + eventId: eventId?.trim() || null, // empty/whitespace string -> null + invoiceId: invoiceId?.trim() || null, // empty/whitespace string -> null
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (2)
apps/web/lib/actions/partners/create-manual-commission.ts(3 hunks)apps/web/lib/partners/create-partner-commission.ts(4 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
- apps/web/lib/actions/partners/create-manual-commission.ts
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/partners/create-partner-commission.ts (3)
packages/prisma/client.ts (1)
CommissionType(8-8)apps/web/lib/types.ts (1)
RewardContext(524-524)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter(5-16)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/partners/create-partner-commission.ts (1)
59-59: Signature/type extraction LGTMSwitching to the exported props type improves reusability and clarity.
|
|
||
| if (commissionType === "sale") { | ||
| return !linkId || !customerId || !saleAmount; | ||
| if (!linkId || !customerId) { |
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.
The form validation no longer checks for required saleAmount when creating new sale events, allowing users to submit invalid sale commissions.
View Details
📝 Patch Details
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
index 21a9f170c..2cec518b6 100644
--- a/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
+++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-commission-sheet.tsx
@@ -271,6 +271,11 @@ function CreateCommissionSheetContent({
return true;
}
+ // For sale commissions when creating new events, require saleAmount
+ if (commissionType === "sale" && !useExistingEvents && !saleAmount) {
+ return true;
+ }
+
return false;
}, [
commissionType,
Analysis
Missing saleAmount validation allows incomplete sale commission creation
What fails: shouldDisableSubmit() in CreateCommissionSheet doesn't validate saleAmount for sale commissions when creating new events, allowing form submission without required sale amount
How to reproduce:
- Open commission creation form
- Select commission type "Sale"
- Choose "Create new events" option
- Fill partner, link, customer, but leave "Sale amount" empty
- Submit form
Result: Form submits successfully, but backend skips sale event creation due to if (saleAmount) check at line 376 in create-manual-commission.ts. User gets incomplete commission with no sale event recorded.
Expected: Form should require saleAmount when commissionType === "sale" and useExistingEvents === false, preventing incomplete commission creation where sale events are expected but not created.
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
♻️ Duplicate comments (5)
apps/web/lib/actions/partners/create-manual-commission.ts (5)
461-473: Don’t nullifyeventId/invoiceIdon existing commissions without an audit path.This breaks provenance and may affect idempotency/reconciliation. Prefer transferring the commission (update partnerId/linkId/customerId), or record a “replacedByCommissionId”/“transferredFromEventId” audit, and keep invoiceId stable. If you must create new commissions, perform a coordinated migration and update dependent queries first.
Run to find dependencies on these fields:
#!/bin/bash set -e rg -n --hidden -S "eventId" -g '!**/node_modules/**' -C2 || true rg -n --hidden -S "invoiceId" -g '!**/node_modules/**' -C2 || true fd schema.prisma | xargs -I{} awk '/^model Commission\s*{/,/}/' {}
332-343: Preserve customer country when generating a click.Without
country, downstream duplication sets customer.country to "Unknown".const generatedClickEvent = recordClickZodSchema.parse({ timestamp: clickTimestamp.toISOString(), identity_hash: customer.externalId || customer.id, click_id: clickId, link_id: link.id, url: link.url, ip: "127.0.0.1", + country: customer.country || "Unknown", continent: customer.country ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA" : "NA", });
441-444: Use the correct customer reference for first‑conversion stats.When duplicating, base the check on the duplicated customer’s state, not the original.
- ...(isFirstConversion({ - customer, - linkId, - }) && { + ...(isFirstConversion({ + customer: useExistingEvents + ? { sales: totalSales, linkId: link.id } + : customer, + linkId, + }) && {
303-316: Do not spread Prisma entity intocreate(); copy safe fields explicitly.Spreading
...customerrisks writing read-only fields (createdAt/updatedAt), counters, and unintended uniques. Compose a whitelist and setprojectId.- return await tx.customer.create({ - data: { - ...customer, - id: duplicateCustomerId, - linkId: link.id, - clickId: clickEventData.click_id, - clickedAt: new Date(clickEventData.timestamp), - country: clickEventData.country, - ...(recordSaleEvents && { - sales: totalSales, - saleAmount: totalSaleAmount, - }), - }, - }); + return await tx.customer.create({ + data: { + id: duplicateCustomerId, + projectId: workspace.id, + name: customer.name ?? null, + email: customer.email ?? null, + avatar: customer.avatar ?? null, + externalId: customer.externalId ?? null, + stripeCustomerId: customer.stripeCustomerId ?? null, + country: clickEventData.country || customer.country || "Unknown", + linkId: link.id, + clickId: clickEventData.click_id, + clickedAt: new Date(clickEventData.timestamp), + ...(recordSaleEvents && { + sales: totalSales, + saleAmount: totalSaleAmount, + }), + }, + });
182-185: Fix crash: spreading possibly-undefinedexistingClickEvent.
{ ...existingClickEvent }throws when no click exists. Also ensure a valid timestamp and preserve country to avoid "Unknown" overwriting.Apply:
- const clickEventData = recordClickZodSchema.parse({ - ...existingClickEvent, - ...newClickAttributes, - }); + const inferredClickTs = + (existingClickEvent?.timestamp as string | undefined) ?? + (existingLeadEvent?.timestamp as string | undefined) ?? + (existingSaleEvents[0]?.timestamp as string | undefined) ?? + new Date().toISOString(); + + const clickEventData = recordClickZodSchema.parse({ + ...(existingClickEvent ?? {}), + timestamp: inferredClickTs, + identity_hash: + existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id, + country: existingClickEvent?.country ?? customer.country ?? "Unknown", + continent: + (existingClickEvent?.country ?? customer.country) + ? COUNTRIES_TO_CONTINENTS[ + (existingClickEvent?.country ?? customer.country)!.toUpperCase() + ] || "NA" + : "NA", + ...newClickAttributes, + });
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/create-manual-commission.ts (3)
419-421: Handle ingestion failures; avoid proceeding on rejected writes.
allSettledlogs but ignores rejections. Gate downstream updates/creations or surface an error.- const res = await Promise.allSettled(tbEventsToRecord); - console.log("recorded events in Tinybird: ", res); + const res = await Promise.allSettled(tbEventsToRecord); + const failures = res.filter((r) => r.status === "rejected"); + if (failures.length > 0) { + throw new Error(`Failed to record ${failures.length} Tinybird event(s).`); + }
168-176: Remove debugconsole.logcalls (PII/noise).These leak event/customer details. Use structured logging behind a debug flag if needed.
- console.log("Found existing click event: ", existingClickEvent); - console.log("Found existing lead event: ", existingLeadEvent); - console.log("Found existing sale events: ", existingSaleEvents); - console.log("Click event to record: ", clickEventData); - console.log("Lead event to record: ", leadEventData); - console.log("Sale events to record: ", saleEventsData); - console.log( - `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `, - duplicatedCustomer, - ); - console.log("New lead event to record: ", leadEventData); - console.log("New sale event to record: ", saleEventData); - console.log("recorded events in Tinybird: ", res); - console.log( - "Final commissions to transfer event ids: ", - finalCommissionsToTransferEventIds, - ); - console.log( - `Updated link${finalCommissionsToTransferEventIds.length > 0 ? " and nullified old commissions" : ""}:`, - updatedRes, - ); - console.log("Commissions to create: ", commissionsToCreate);Also applies to: 187-187, 203-205, 249-251, 319-322, 356-356, 392-392, 420-421, 429-432, 476-479, 481-482
452-457: Update customer stats on new sale flow for consistency.When
!useExistingEventsand a sale is created, also updatecustomer.salesandcustomer.saleAmount.const updatedRes = await Promise.all([ // update link stats prisma.link.update({ where: { id: link.id, }, data: { @@ saleAmount: { increment: saleAmount ?? totalSaleAmount, }, }, }), + !useExistingEvents && + !!saleAmount && + prisma.customer.update({ + where: { id: customer.id }, + data: { + sales: { increment: 1 }, + saleAmount: { increment: saleAmount }, + }, + }), // update the commissions finalCommissionsToTransferEventIds.length > 0 && prisma.commission.updateMany({
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/actions/partners/create-manual-commission.ts(3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (10)
apps/web/lib/zod/schemas/leads.ts (1)
leadEventSchemaTB(98-107)apps/web/lib/zod/schemas/sales.ts (1)
saleEventSchemaTB(120-137)apps/web/lib/zod/schemas/commissions.ts (1)
createCommissionSchema(131-153)apps/web/lib/partners/create-partner-commission.ts (2)
CreatePartnerCommissionProps(25-41)createPartnerCommission(43-328)apps/web/lib/tinybird/get-customer-events-tb.ts (1)
getCustomerEventsTB(10-21)apps/web/lib/tinybird/record-click-zod.ts (2)
recordClickZodSchema(4-34)recordClickZod(36-40)apps/web/lib/api/create-id.ts (1)
createId(62-67)packages/utils/src/constants/continents.ts (1)
COUNTRIES_TO_CONTINENTS(13-264)apps/web/lib/analytics/is-first-conversion.ts (1)
isFirstConversion(3-23)apps/web/lib/api/workflows/execute-workflows.ts (1)
executeWorkflows(17-122)
⏰ 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). (2)
- GitHub Check: Vade Review
- GitHub Check: build
🔇 Additional comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
238-251: recordSaleWithTimestamp bulk ingest OKtb.buildIngestEndpoint (zod-bird) accepts either a single event object or an array (bulk NDJSON) and is awaitable; Tinybird Events API supports batched NDJSON ingest. (github.com)
| eventId: leadEventData.event_id, | ||
| quantity: 1, | ||
| createdAt: finalLeadEventDate, | ||
| createdAt: new Date(leadEventData.timestamp + "Z"), |
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.
Fix invalid Date construction: don’t append "Z" to an ISO string that already has "Z".
toISOString() returns a UTC string with "Z". Adding another "Z" yields Invalid Date.
- createdAt: new Date(leadEventData.timestamp + "Z"),
+ createdAt: new Date(leadEventData.timestamp),- createdAt: new Date(saleEventData.timestamp + "Z"),
+ createdAt: new Date(saleEventData.timestamp),Also applies to: 408-408
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/create-manual-commission.ts around lines 369
and 408, the code appends an extra "Z" to a timestamp string that may already
include "Z", producing Invalid Date; change the construction to not add a second
"Z" (e.g., pass the timestamp directly into new Date or strip a trailing "Z"
before appending) so the Date is parsed correctly.
| const clickEventData = recordClickZodSchema.parse({ | ||
| ...existingClickEvent, | ||
| ...newClickAttributes, | ||
| }); | ||
|
|
||
| console.log("Click event to record: ", clickEventData); | ||
| if (existingClickEvent) { | ||
| tbEventsToRecord.push(recordClickZod(clickEventData)); | ||
| } |
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.
When using existing events, if no existing click event is found, the code will still try to use clickEventData (which contains undefined values) in lead and sale event creation, causing runtime errors.
View Details
📝 Patch Details
diff --git a/apps/web/lib/actions/partners/create-manual-commission.ts b/apps/web/lib/actions/partners/create-manual-commission.ts
index cb05d31af..1eaa7ac5f 100644
--- a/apps/web/lib/actions/partners/create-manual-commission.ts
+++ b/apps/web/lib/actions/partners/create-manual-commission.ts
@@ -175,6 +175,12 @@ export const createManualCommissionAction = authActionClient
);
console.log("Found existing sale events: ", existingSaleEvents);
+ if (!existingClickEvent) {
+ throw new Error(
+ `No existing click event found for customer ${customer.id}. Cannot duplicate events without a click event.`,
+ );
+ }
+
const newClickAttributes = {
click_id: nanoid(16), // create new clickId
link_id: link.id, // set to new link.id,
Analysis
Runtime error in createManualCommissionAction when duplicating events without existing click event
What fails: leadEventSchemaTBWithTimestamp.parse() and saleEventSchemaTBWithTimestamp.parse() fail with Zod validation errors when existingClickEvent is undefined
How to reproduce:
// In createManualCommissionAction with useExistingEvents=true
// If customer has lead/sale events but no click event in existingCustomerEvents
const existingClickEvent = undefined;
const clickEventData = recordClickZodSchema.parse({
...existingClickEvent, // spreads nothing - missing required fields
...newClickAttributes,
});
// Later: leadEventSchemaTBWithTimestamp.parse({...clickEventData, ...}) failsResult: Zod validation error: Required for fields like url, timestamp when clickEventData only contains click_id and link_id
Expected: Should either generate synthetic click event data or throw clear error preventing invalid state
Root cause: Spreading undefined adds no properties, leaving required schema fields missing from clickEventData
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: 2
♻️ Duplicate comments (5)
apps/web/lib/actions/partners/create-manual-commission.ts (5)
441-444: Use the correct customer reference for first-conversion check.When duplicating, pass the duplicated customer context (or derived stats), not the original.
- ...(isFirstConversion({ - customer, - linkId, - }) && { + ...(isFirstConversion({ + customer: useExistingEvents + ? { sales: totalSales, linkId: link.id } + : customer, + linkId, + }) && {
333-343: Include country in generated click event to avoid “Unknown” overwrite.Without
country, you later persistUnknowninto the duplicated customer.const generatedClickEvent = recordClickZodSchema.parse({ timestamp: clickTimestamp.toISOString(), identity_hash: customer.externalId || customer.id, click_id: clickId, link_id: link.id, url: link.url, - ip: "127.0.0.1", + ip: "127.0.0.1", + country: customer.country || "Unknown", continent: customer.country ? COUNTRIES_TO_CONTINENTS[customer.country.toUpperCase()] || "NA" : "NA", });
165-177: Remove debug console logs (PII/noisy) or gate behind a debug flag.These leak customer/event details into prod logs. Use structured logging only if essential.
Example deletions:
- console.log("Found existing click event: ", existingClickEvent); - console.log("Found existing lead event: ", existingLeadEvent); - console.log("Found existing sale events: ", existingSaleEvents); - console.log("Click event to record: ", clickEventData); - console.log("Lead event to record: ", leadEventData); - console.log("Sale events to record: ", saleEventsData); - console.log( - `Duplicated customer ${customer.id} to ${duplicatedCustomer.id}: `, - duplicatedCustomer, - ); - console.log("New lead event to record: ", leadEventData); - console.log("New sale event to record: ", saleEventData); - console.log("recorded events in Tinybird: ", res); - console.log( - "Final commissions to transfer event ids: ", - finalCommissionsToTransferEventIds, - ); - console.log( - `Updated link${finalCommissionsToTransferEventIds.length > 0 ? " and nullified old commissions" : ""}:`, - updatedRes, - ); - console.log("Commissions to create: ", commissionsToCreate);Also applies to: 187-191, 203-205, 249-251, 319-322, 356-357, 392-395, 420-421, 429-432, 476-482
303-316: Don’t spread a Prisma entity into create(); copy safe fields and null unique/external IDs.Spreading
...customerrisks unique constraint violations (e.g., external/stripe IDs), wrong timestamps, and copying counters. Create a clean payload.- return await tx.customer.create({ - data: { - ...customer, - id: duplicateCustomerId, - linkId: link.id, - clickId: clickEventData.click_id, - clickedAt: new Date(clickEventData.timestamp), - country: clickEventData.country, - ...(recordSaleEvents && { - sales: totalSales, - saleAmount: totalSaleAmount, - }), - }, - }); + return await tx.customer.create({ + data: { + id: duplicateCustomerId, + name: customer.name, + email: customer.email, + avatar: customer.avatar, + externalId: null, + stripeCustomerId: null, + country: clickEventData.country ?? customer.country ?? "Unknown", + projectId: workspace.id, + linkId: link.id, + clickId: clickEventData.click_id, + clickedAt: new Date( + existingClickEvent?.timestamp ?? + existingLeadEvent?.timestamp ?? + existingSaleEvents[0]?.timestamp ?? + clickEventData.timestamp, + ), + ...(recordSaleEvents && { + sales: totalSales, + saleAmount: totalSaleAmount, + }), + }, + });
178-190: Fix crash when no existing click event (unsafe spread of undefined).
{ ...existingClickEvent }throws if no click exists. Also ensure a sane timestamp and identity hash fallback.Apply:
- const clickEventData = recordClickZodSchema.parse({ - ...existingClickEvent, - ...newClickAttributes, - }); + const inferredClickTs = + (existingClickEvent?.timestamp as string | undefined) ?? + (existingLeadEvent?.timestamp as string | undefined) ?? + (existingSaleEvents[0]?.timestamp as string | undefined) ?? + new Date().toISOString(); + + const clickEventData = recordClickZodSchema.parse({ + ...(existingClickEvent ?? {}), + timestamp: inferredClickTs, + identity_hash: + existingClickEvent?.identity_hash ?? customer.externalId ?? customer.id, + ...newClickAttributes, + });
🧹 Nitpick comments (3)
apps/web/lib/actions/partners/create-manual-commission.ts (3)
441-457: Link stats: lead increments should not depend on first-conversion.You currently bump
leadsonly whenisFirstConversionis true. Leads recorded (both paths) should incrementleadsirrespective of conversion logic.Suggestion: increment
leadswhen a lead event was recorded in this action; keepconversionsguarded byisFirstConversion.
24-24: Import path nit: avoid deep “/src” import.Prefer
@dub/utils(if exported) to reduce coupling to internal paths.
115-133: Invoice uniqueness: duplication path may race with nullification.You check
invoiceIdonly for user-supplied sales. When duplicating existing sale events, uniqueness relies on the later nullification. Consider guarding duplicates with a pre-check or wrapping the nullify+create in a single transaction to avoid races.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
apps/web/lib/actions/partners/create-manual-commission.ts(3 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-17T17:44:03.921Z
Learnt from: TWilson023
PR: dubinc/dub#2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.921Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.
Applied to files:
apps/web/lib/actions/partners/create-manual-commission.ts
🧬 Code graph analysis (1)
apps/web/lib/actions/partners/create-manual-commission.ts (9)
apps/web/lib/zod/schemas/leads.ts (1)
leadEventSchemaTB(98-107)apps/web/lib/zod/schemas/sales.ts (1)
saleEventSchemaTB(120-137)apps/web/lib/zod/schemas/commissions.ts (1)
createCommissionSchema(131-153)apps/web/lib/partners/create-partner-commission.ts (2)
CreatePartnerCommissionProps(25-41)createPartnerCommission(43-328)apps/web/lib/tinybird/get-customer-events-tb.ts (1)
getCustomerEventsTB(10-21)apps/web/lib/tinybird/record-click-zod.ts (2)
recordClickZodSchema(4-34)recordClickZod(36-40)apps/web/lib/api/create-id.ts (1)
createId(62-67)apps/web/lib/analytics/is-first-conversion.ts (1)
isFirstConversion(3-23)apps/web/lib/api/workflows/execute-workflows.ts (1)
executeWorkflows(17-122)
⏰ 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: Vade Review
🔇 Additional comments (1)
apps/web/lib/actions/partners/create-manual-commission.ts (1)
249-251: Confirm recordSaleWithTimestamp supports array payloads.Else enqueue each event individually to avoid ingestion failure.
Fallback if needed:
- tbEventsToRecord.push(recordSaleWithTimestamp(saleEventsData)); + for (const e of saleEventsData) { + tbEventsToRecord.push(recordSaleWithTimestamp(e)); + }
| createdAt: new Date(leadEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC | ||
| user, | ||
| context: { |
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.
Stop appending an extra "Z" to ISO timestamps.
toISOString() already includes “Z”; appending another yields Invalid Date. Use the timestamp as-is.
- createdAt: new Date(leadEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC
+ createdAt: new Date(leadEventData.timestamp),- createdAt: new Date(saleEventData.timestamp + "Z"), // add the "Z" to the timestamp to make it UTC
+ createdAt: new Date(saleEventData.timestamp),Also applies to: 272-275
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/create-manual-commission.ts around lines
219-221 and 272-275, the code appends an extra "Z" to timestamps created from
toISOString(), producing invalid dates; remove the concatenated "Z" and pass the
timestamp string (or Date object) directly to new Date(...) so you rely on the
already-UTC ISO format, ensuring valid Date construction and consistent timezone
handling.
| // update the commissions | ||
| finalCommissionsToTransferEventIds.length > 0 && | ||
| prisma.commission.updateMany({ | ||
| where: { | ||
| eventId: { | ||
| in: finalCommissionsToTransferEventIds, | ||
| }, | ||
| }, | ||
| data: { | ||
| eventId: null, | ||
| invoiceId: null, | ||
| }, | ||
| }), | ||
| ]); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
🧩 Analysis chain
Don’t nullify eventId/invoiceId — preserve provenance or record a transfer.
Blanking identifiers breaks dedupe, exports, and auditability. Prefer marking old commissions as transferred/clawed-back and linking via a transfer/audit record, or add fields like transferredFromEventId. Coordinate schema/consumers before changing.
I need to examine the codebase to understand the context of this commission management system and verify whether nullifying eventId and invoiceId is indeed problematic for data integrity and auditability.
🏁 Script executed:
#!/bin/bash
# First, let's examine the file in question to understand the full context
cat apps/web/lib/actions/partners/create-manual-commission.tsLength of output: 16380
🏁 Script executed:
#!/bin/bash
# Look for the Commission model/schema to understand the data structure
ast-grep --pattern 'model Commission {
$$$
}'Length of output: 0
🏁 Script executed:
#!/bin/bash
# Search for Commission schema definition in Prisma files
fd -e prisma -x cat {}Length of output: 50721
🏁 Script executed:
#!/bin/bash
# Search for any references to Commission model across the codebase
rg -n --type=ts -C3 'Commission.*{' -g '!node_modules/**'Length of output: 89
🏁 Script executed:
#!/bin/bash
# Look for existing patterns of handling commission transfers or updates
rg -n --type=ts -C5 'commission.*transfer' -g '!node_modules/**'Length of output: 148
🏁 Script executed:
#!/bin/bash
# Search for other places where eventId or invoiceId are being nullified
rg -n --type=ts -C3 'eventId.*null|invoiceId.*null' -g '!node_modules/**'Length of output: 148
Continuing verification of nullification patterns and related logic:
🏁 Script executed:
#!/bin/bash
# Find where eventId is set to null
rg -n "eventId: null" -g "apps/web/lib/**"
# Find where invoiceId is set to null
rg -n "invoiceId: null" -g "apps/web/lib/**"
# Locate commission.updateMany usages in this module
rg -n "commission.updateMany" -g "apps/web/lib/**"
# Inspect createPartnerCommission implementation
rg -n "export function createPartnerCommission" -g "apps/web/lib/partners/create-partner-commission.ts"Length of output: 754
Preserve provenance — don’t nullify eventId/invoiceId. Replace the prisma.commission.updateMany call in apps/web/lib/actions/partners/create-manual-commission.ts (461-474) so that old commissions are marked (e.g. status=duplicate or via a new transferredFromEventId/audit record) instead of clearing their eventId/invoiceId.
Summary by CodeRabbit
New Features
Improvements
Chores