-
Notifications
You must be signed in to change notification settings - Fork 2.7k
FEAT: Bounties #2736
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
FEAT: Bounties #2736
Conversation
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Warning Rate limit exceeded@steven-tey has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 18 minutes and 39 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (1)
WalkthroughAdds a full "bounties" feature: DB models and enums, APIs (CRUD, submissions, counts, partner-facing), workflow engine and triggers/actions, partner server actions and UI, email templates/webhooks, SWR hooks/types, cron notifier, utilities, tests, and related UI/component changes. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Partner
participant UI as Partner UI
participant API as Server (partner actions)
participant DB as Prisma
participant Store as R2/Storage
participant Mail as Email Service
Partner->>UI: Open claim modal & attach image
UI->>API: uploadBountySubmissionFileAction(programId,bountyId)
API->>DB: validate enrollment & bounty
API->>Store: request signed PUT URL
API-->>UI: signedUrl + destinationUrl
UI->>Store: PUT file to signedUrl
UI->>API: createBountySubmissionAction(files, urls, description)
API->>DB: create BountySubmission (pending)
API-->>Mail: batch notify program owners (Pending) and notify partner (Submitted)
API-->>UI: { success: true }
sequenceDiagram
autonumber
actor System
participant Event as Lead/Sale Event
participant Server as API
participant WF as executeWorkflows
participant DB as Prisma
Event->>Server: process lead/sale
Server->>WF: executeWorkflows({ programId, partnerId, trigger })
WF->>DB: load workflows + enrollment metrics
alt condition satisfied
WF->>DB: execute action awardBounty -> create commission & bountySubmission
DB-->>WF: commission + submission created
WF-->>Server: action completed
else
WF-->>Server: no-op
end
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120+ minutes Possibly related PRs
Poem
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. 🪧 TipsChatThere are 3 ways to chat with CodeRabbit:
SupportNeed help? Create a ticket on our support page for assistance with any issues or questions. CodeRabbit Commands (Invoked using PR/Issue comments)Type Other keywords and placeholders
CodeRabbit Configuration File (
|
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 (1)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (1)
94-96
: Stop logging partner emails (PII).Do not print raw email addresses to logs.
- console.log( - `Sending emails to ${programEnrollmentChunk.length} partners: ${programEnrollmentChunk.map(({ partner }) => partner.email).join(", ")}` - ); + console.info( + `Sending ${programEnrollmentChunk.length} bounty emails (page ${page}, bounty ${bountyId}).`, + );
🧹 Nitpick comments (5)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (5)
20-21
: Define a named batch size constant.Improves readability and keeps batch size consistent.
const MAX_PAGE_SIZE = 5000; + +const BATCH_SIZE = 100;
138-140
: Report actual recipients, not enrollments, in completion log.Aligns logs with real sends.
- return logAndRespond( - `Finished sending emails to ${programEnrollments.length} partners for bounty ${bountyId}.`, - ); + return logAndRespond( + `Finished sending emails to ${typeof recipients !== "undefined" ? recipients.length : programEnrollments.filter(({ partner }) => Boolean(partner.email)).length} partners for bounty ${bountyId}.`, + );
46-50
: Return 404 for missing bounty.Better semantics and easier to monitor.
- if (!bounty) { - return logAndRespond(`Bounty ${bountyId} not found.`, { - logLevel: "error", - }); - } + if (!bounty) { + return logAndRespond(`Bounty ${bountyId} not found.`, { + logLevel: "error", + status: 404, + }); + }
141-145
: Type-safe error logging in catch.Avoids potential TS errors when
useUnknownInCatchVariables
is enabled.- await log({ - message: "New bounties published cron failed. Error: " + error.message, - type: "errors", - }); + const msg = + error instanceof Error ? error.message : JSON.stringify(error); + await log({ + message: `New bounties published cron failed. Error: ${msg}`, + type: "errors", + });
97-121
: Fail fast if email provider is not configured.Optional, but avoids silently dropping emails when
resend
is undefined.Consider adding a guard before sending:
+ if (!resend) { + return logAndRespond( + "Email provider not configured; cannot send bounty notifications.", + { status: 500, logLevel: "error" }, + ); + }Would you like me to add this guard?
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (1)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📓 Common learnings
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
🧬 Code graph analysis (1)
apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts (5)
apps/web/app/(ee)/api/bounties/route.ts (1)
POST
(72-209)apps/web/app/(ee)/api/cron/utils.ts (1)
logAndRespond
(1-13)packages/email/src/templates/new-bounty-available.tsx (1)
NewBountyAvailable
(18-102)packages/utils/src/constants/main.ts (1)
APP_DOMAIN_WITH_NGROK
(20-25)apps/web/lib/api/errors.ts (1)
handleAndReturnErrorResponse
(175-181)
⏰ 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: 12
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (9)
apps/web/ui/partners/partner-application-sheet.tsx (1)
208-240
: Harden createLink: normalize inputs and guard error pathsCurrent logic can mis-handle full URLs (with protocol) and may throw if the error payload isn’t shaped as expected. Also, it proceeds when program config is missing.
Apply this diff to make input handling and error surfacing robust:
- const createLink = async (search: string) => { - if (!search) throw new Error("No link entered") - - const shortKey = search.startsWith(program?.domain + "/") - ? search.substring((program?.domain + "/").length) - : search - - const response = await fetch(`/api/links?workspaceId=${workspaceId}`, { + const createLink = async (search: string) => { + const raw = (search || "").trim() + if (!raw) throw new Error("No link entered") + if (!program?.id || !program?.domain || !program?.url) { + throw new Error("Program is not fully configured (id/domain/url).") + } + + // Accept either a short key or a full short URL (with/without protocol) + let shortKey = raw + try { + // Handle full URL (https://domain/key or http://domain/key) + const u = new URL(raw) + if (u.host === program.domain && u.pathname.length > 1) { + shortKey = u.pathname.replace(/^\/+/, "") + } + } catch { + // Not a valid URL; try "domain/key" without protocol + const noProto = raw.replace(/^(https?:)?\/\//i, "") + const prefix = program.domain + "/" + if (noProto.startsWith(prefix)) { + shortKey = noProto.substring(prefix.length) + } + } + + const response = await fetch(`/api/links?workspaceId=${workspaceId}`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ - domain: program?.domain, + domain: program.domain, key: shortKey, - url: program?.url, + url: program.url, trackConversion: true, - programId: program?.id, - folderId: program?.defaultFolderId, + programId: program.id, + folderId: program?.defaultFolderId, }), }) - - const result = await response.json() - - if (!response.ok) { - const { error } = result - throw new Error(error.message) - } - - setSelectedLinkId(result.id) - - return result.id + let result: any = null + try { + result = await response.json() + } catch { + // noop; keep result as null + } + if (!response.ok) { + const message = + (result?.error?.message as string) || + (result?.message as string) || + "Failed to create link" + throw new Error(message) + } + setSelectedLinkId(result.id) + return result.id }apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/partners/partners-table.tsx (1)
313-322
: Sorting bug: column id mismatch (“commissions” vs “totalCommissions”).The defined column id is
totalCommissions
, so sorting on “Commissions” won’t work.sortableColumns: [ "createdAt", "clicks", "leads", "conversions", "sales", "saleAmount", - "commissions", + "totalCommissions", "netRevenue", ],apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/commissions/create-clawback-sheet.tsx (1)
68-72
: Avoid floating-point money errors; round to cents before sending.Multiplying a JS float by 100 can produce 1–2¢ errors (e.g., 0.29 * 100 → 28.999…). Round to an integer minor unit.
- await executeAsync({ - ...data, - amount: data.amount * 100, - workspaceId, - }); + const amountInCents = Math.round(Number(data.amount) * 100); + await executeAsync({ + ...data, + amount: amountInCents, + workspaceId, + });packages/email/src/templates/program-application-reminder.tsx (1)
34-36
: Fix product-name inconsistency: “Dub Partner” vs “Dub Partners”Use “Dub Partners” consistently (Preview currently says “Dub Partner”). Also aligns with the URL and product naming elsewhere.
Apply this diff:
- Your application to {program.name} has been saved, but you still need to - create your Dub Partner account to complete your application. + Your application to {program.name} has been saved, but you still need to + create your Dub Partners account to complete your application.Also applies to: 49-52
apps/web/lib/actions/partners/mark-commission-duplicate.ts (1)
52-64
: Make payout + commission updates atomic; guard against negative amountsTwo writes occur separately and can leave data inconsistent if one fails or under concurrency. Wrap in a single Prisma transaction and clamp the revised payout amount to ≥ 0.
- if (commission.payout) { - const earnings = commission.earnings; - const revisedAmount = commission.payout.amount - earnings; - - await prisma.payout.update({ - where: { - id: commission.payout.id, - }, - data: { - amount: revisedAmount, - }, - }); - } - - await prisma.commission.update({ - where: { - id: commission.id, - }, - data: { - status: "duplicate", - payoutId: null, - }, - }); + await prisma.$transaction(async (tx) => { + if (commission.payout) { + const earnings = commission.earnings; + const revisedAmount = Math.max( + 0, + commission.payout.amount - earnings, + ); + await tx.payout.update({ + where: { id: commission.payout.id }, + data: { amount: revisedAmount }, + }); + } + await tx.commission.update({ + where: { id: commission.id }, + data: { + status: "duplicate", + payoutId: null, + }, + }); + });If these fields are Prisma Decimal, prefer precise arithmetic:
// Example if using Prisma.Decimal import { Prisma } from "@prisma/client"; const revisedAmount = Prisma.Decimal.max( new Prisma.Decimal(0), new Prisma.Decimal(commission.payout.amount).minus(commission.earnings), );Also applies to: 66-74
apps/web/ui/shared/animated-empty-state.tsx (1)
61-73
: Add rel when using target="_blank" to prevent reverse tabnabbingInclude
rel="noopener noreferrer"
with external targets.- <Link + <Link href={learnMoreHref} target={learnMoreTarget} + rel={learnMoreTarget === "_blank" ? "noopener noreferrer" : undefined} className={cn(apps/web/lib/api/create-id.ts (1)
2-2
: Harden randomness source across runtimes (Node/web).
crypto.getRandomValues
isn’t available on the Node “crypto” module import; prefer global webcrypto (or Node’scrypto.webcrypto
) with arandomFillSync
fallback to avoid runtime failures and ensure strong entropy.-import crypto from "crypto"; +import { webcrypto as nodeWebCrypto, randomFillSync } from "crypto"; @@ - // Randomness (80 bits = 10 bytes) - crypto.getRandomValues(buf.subarray(6)); + // Randomness (80 bits = 10 bytes) + const wc: Crypto | undefined = + typeof globalThis !== "undefined" + ? ((globalThis as any).crypto as Crypto | undefined) ?? nodeWebCrypto + : nodeWebCrypto; + if (wc?.getRandomValues) { + wc.getRandomValues(buf.subarray(6)); + } else { + // Node fallback + randomFillSync(buf, 6); + }Also applies to: 54-54
apps/web/ui/partners/partner-details-sheet.tsx (1)
152-159
: Fix cents-based decimal check for Revenue formattingsaleAmount is stored in cents; use % 100 to decide if there are fractional dollars.
- : currencyFormatter(partner.saleAmount / 100, { - minimumFractionDigits: - partner.saleAmount % 1 === 0 ? 0 : 2, - maximumFractionDigits: 2, - }), + : currencyFormatter(partner.saleAmount / 100, { + minimumFractionDigits: + partner.saleAmount % 100 === 0 ? 0 : 2, + maximumFractionDigits: 2, + }),apps/web/app/(ee)/api/stripe/integration/webhook/checkout-session-completed.ts (1)
339-347
: GuardgetSubscriptionProductId
whencharge.subscription
is null for one-time payments.
charge.subscription as string
will passnull
at runtime on one-time payments and may throw insidegetSubscriptionProductId
. Guard and passundefined
or skip.- const productId = await getSubscriptionProductId({ - stripeSubscriptionId: charge.subscription as string, - stripeAccountId, - livemode: event.livemode, - }); + const productId = charge.subscription + ? await getSubscriptionProductId({ + stripeSubscriptionId: charge.subscription, + stripeAccountId, + livemode: event.livemode, + }) + : undefined;
...b.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
Outdated
Show resolved
Hide resolved
...ub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts
Show resolved
Hide resolved
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx
Outdated
Show resolved
Hide resolved
…ommissions button
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 (16)
apps/web/ui/partners/bounties/bounty-logic.tsx (2)
55-62
: Reset value when attribute changes to avoid stale/invalid state.Clears the numeric value when switching between count/currency attributes to prevent mixed units.
- const { control, watch } = useAddEditBountyForm(); + const { control, watch, setValue, clearErrors } = useAddEditBountyForm();- <InlineBadgePopoverMenu - selectedValue={field.value} - onSelect={field.onChange} + <InlineBadgePopoverMenu + selectedValue={field.value} + onSelect={(newAttr) => { + field.onChange(newAttr); + setValue("performanceCondition.value", undefined, { + shouldDirty: true, + shouldValidate: true, + }); + clearErrors?.("performanceCondition.value"); + }} items={WORKFLOW_ATTRIBUTES.map((attribute) => ({ text: WORKFLOW_ATTRIBUTE_LABELS[attribute], value: attribute, }))} />Also applies to: 24-24
72-79
: “0” is valid but currently marked invalid (falsy check).Don’t treat 0 as invalid.
text={ value ? isCurrencyAttribute(attribute) ? currencyFormatter(value) - : value + : value : "amount" } - invalid={!value} + invalid={value === undefined || value === null}apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx (5)
220-221
: Round dollars→cents to avoid float precision errors.Prevent off-by-one cents (e.g., 12.34*100 → 1233.999…).
- data.rewardAmount = data.rewardAmount * 100; + data.rewardAmount = Math.round(data.rewardAmount * 100);- value: isCurrency ? condition.value * 100 : condition.value, + value: isCurrency ? Math.round(condition.value * 100) : condition.value,Also applies to: 241-242
245-247
: Use submitted data.type instead of potentially stale watched value.Prevents mis-routing if type changed just before submit.
- } else if (type === "submission") { + } else if (data.type === "submission") { data.performanceCondition = null; }
332-389
: “Add end date” toggle is hidden when unchecked → impossible to enable.Render the Switch always; only collapse the picker.
- <AnimatedSizeContainer - height - transition={{ ease: "easeInOut", duration: 0.2 }} - className={!hasEndDate ? "hidden" : ""} - style={{ display: !hasEndDate ? "none" : "block" }} - > - <div className="flex items-center gap-4"> - <Switch - fn={setHasEndDate} - checked={hasEndDate} - 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"> - Add end date - </h3> - </div> - </div> - - {hasEndDate && ( + <div className="mt-2 flex items-center gap-4"> + <Switch + fn={setHasEndDate} + checked={hasEndDate} + 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"> + Add end date + </h3> + </div> + </div> + + <AnimatedSizeContainer + height + transition={{ ease: "easeInOut", duration: 0.2 }} + > + {hasEndDate && ( <div className="mt-6 p-px"> <SmartDateTimePicker value={watch("endsAt")} onChange={(date) => { setValue("endsAt", date, { shouldDirty: true, }); }} label="End date" placeholder='E.g. "in 3 months"' /> </div> )} - </AnimatedSizeContainer> + </AnimatedSizeContainer>
347-347
: Remove stray “test” text from error slot.- {errors.startsAt && "test"} + {/* Optional: render a proper message if rules/resolver provide one */} + {errors.startsAt && null}
402-406
: Align client-side rules with Zod schema (min 1).Server requires rewardAmount >= 1.
- rules={{ - required: true, - min: 0, - }} + rules={{ + required: "Reward amount is required", + min: { value: 1, message: "Reward amount must be greater than 1" }, + }}apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (2)
143-151
: Show $0.00 instead of “-” for zero earnings.Truthiness check hides valid zero values. Use null/undefined check.
- value: commission?.earnings + value: commission?.earnings != null ? currencyFormatter(commission.earnings / 100, { minimumFractionDigits: 2, maximumFractionDigits: 2, }) : "-",
203-221
: Add missing key prop to mapped URL items.Fix Biome’s useJsxKeyInIterable and React reconciliation.
- {submission.urls?.map((url) => ( - <div className="relative"> + {submission.urls?.map((url, idx) => ( + <div className="relative" key={`${idx}-${url}`}>apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts (1)
105-113
: Don’t spread ProgramEnrollment onto partner; preserve partner.id and fill required metrics.Spreading risks id clobbering and missing metrics (leads, conversions, saleAmount). Build partner explicitly and default metrics to 0 to satisfy BountySubmissionExtendedSchema.
- return submissions.map((submission) => { - return { - partner: { - ...submission.programEnrollment?.partner, - ...submission.programEnrollment, - // here's we're making sure the programEnrollment ID doesn't override the actual partner ID - // TODO: this is a bit messy, we should refactor this - id: submission.programEnrollment?.partnerId, - }, - submission, - commission: submission.commission, - user: submission.user, - }; - }); + return submissions.map((s) => { + const pe = s.programEnrollment!; + return { + partner: { + // core partner fields + id: pe.partnerId, + name: pe.partner?.name ?? null, + email: pe.partner?.email ?? null, + image: pe.partner?.image ?? null, + country: pe.partner?.country ?? null, + payoutsEnabledAt: pe.partner?.payoutsEnabledAt ?? null, + bannedAt: pe.partner?.bannedAt ?? null, + bannedReason: pe.partner?.bannedReason ?? null, + // enrollment + groupId: pe.groupId ?? null, + status: pe.status ?? null, + totalCommissions: pe.totalCommissions ?? 0, + // metrics not joined in this path + leads: 0, + conversions: 0, + saleAmount: 0, + }, + submission: s, + commission: s.commission, + user: s.user, + }; + });apps/web/lib/actions/partners/approve-bounty-submission.ts (4)
23-27
: Enforce role-based authorization before approval.Restrict to owner/admin (or your chosen roles) to prevent unauthorized payouts.
.action(async ({ parsedInput, ctx }) => { const { workspace, user } = ctx; + if (!["owner", "admin"].includes(workspace.role)) { + throw new Error("Forbidden: insufficient permissions to approve bounties."); + }
44-46
: Idempotency: also guard on existing commissionId.Prevents duplicate approvals via alternative code paths.
- if (bountySubmission.status === "approved") { + if (bountySubmission.status === "approved" || bountySubmission.commissionId) { throw new Error("Bounty submission already approved."); }
48-57
: Validate reward amount > 0.Avoid zero/invalid payouts.
if (bounty.type === "performance") { throw new Error("Performance based bounties cannot be approved."); } + if (!bounty.rewardAmount || bounty.rewardAmount <= 0) { + throw new Error("Bounty reward amount is missing or invalid."); + }
52-78
: Race-safe, idempotent approval to prevent duplicate commissions.Use a conditional status flip before commission creation; roll back on failure. Optionally pass eventId=submissionId for dedupe.
- const commission = await createPartnerCommission({ + // Optimistic guard to avoid double-approval + const updated = await prisma.bountySubmission.updateMany({ + where: { id: submissionId, status: { not: "approved" }, commissionId: null }, + data: { + status: "approved", + reviewedAt: new Date(), + userId: user.id, + rejectionNote: null, + rejectionReason: null, + }, + }); + if (updated.count === 0) { + throw new Error("Bounty submission already approved."); + } + + const commission = await createPartnerCommission({ event: "custom", partnerId: bountySubmission.partnerId, programId: bountySubmission.programId, amount: bounty.rewardAmount, quantity: 1, user, - description: `Commission for successfully completed "${bounty.name}" bounty.`, + eventId: submissionId, + description: `Commission for successfully completed "${bounty.name || "Untitled Bounty"}" bounty.`, }); if (!commission) { - throw new Error("Failed to create commission for the bounty submission."); + // rollback status flip + await prisma.bountySubmission.update({ + where: { id: submissionId }, + data: { status: "pending", reviewedAt: null, userId: null }, + }); + throw new Error("Failed to create commission for the bounty submission."); } - await prisma.bountySubmission.update({ - where: { - id: submissionId, - }, - data: { - status: "approved", - reviewedAt: new Date(), - userId: user.id, - rejectionNote: null, - rejectionReason: null, - commissionId: commission.id, - }, - }); + await prisma.bountySubmission.update({ + where: { id: submissionId }, + data: { commissionId: commission.id }, + });packages/prisma/schema/bounty.prisma (2)
38-39
: Avoid cascading delete of Bounty when Workflow is removed.Prefer onDelete: SetNull to prevent accidental bounty loss.
- workflow Workflow? @relation(fields: [workflowId], references: [id], onDelete: Cascade) + workflow Workflow? @relation(fields: [workflowId], references: [id], onDelete: SetNull)
75-76
: Avoid deleting submissions when a Commission is removed.Use SetNull so submission history remains.
- commission Commission? @relation(fields: [commissionId], references: [id], onDelete: Cascade) + commission Commission? @relation(fields: [commissionId], references: [id], onDelete: SetNull)
🧹 Nitpick comments (8)
apps/web/lib/api/bounties/generate-bounty-name.ts (4)
6-12
: Add explicit return type and document money units.Make intent and units unambiguous for callers and reviewers.
-export const generateBountyName = ({ +/** + * rewardAmount is in cents. If condition.attribute is currency-based, + * condition.value is also expected in cents. + */ +export const generateBountyName = ({ rewardAmount, condition, }: { rewardAmount: number; condition?: WorkflowCondition | null; -}) => { +}): string => {
3-3
: Support non-USD workspaces and avoid silently rounding cents.Today we always format in USD and with 0 decimals (per currencyFormatter defaults), which can misrepresent values (e.g., $12.50 → $13) and breaks for non-USD workspaces. Accept an optional currency and format up to 2 fraction digits.
-import { currencyFormatter, nFormatter } from "@dub/utils"; +import { currencyFormatter, nFormatter } from "@dub/utils"; @@ -export const generateBountyName = ({ - rewardAmount, - condition, -}: { - rewardAmount: number; - condition?: WorkflowCondition | null; -}) => { +export const generateBountyName = ({ + rewardAmount, + condition, + currency, +}: { + rewardAmount: number; + condition?: WorkflowCondition | null; + currency?: string; // ISO 4217, e.g. "USD" +}): string => { + const cf = (v: number) => + currencyFormatter(v, { maximumFractionDigits: 2 }, currency); @@ - if (!condition) { - return `Earn ${currencyFormatter(rewardAmount / 100)}`; - } + if (!condition) { + return `Earn ${cf(rewardAmount / 100)}`; + } @@ - const rewardAmountFormatted = currencyFormatter(rewardAmount / 100); + const rewardAmountFormatted = cf(rewardAmount / 100); @@ - const valueFormatted = isCurrency - ? `${currencyFormatter(condition.value / 100)} in` + const valueFormatted = isCurrency + ? `${cf(condition.value / 100)} in` : `${nFormatter(condition.value, { full: true })}`;Also applies to: 6-12, 14-14, 18-18, 21-21
19-24
: Fix grammar for singular metrics and add a safe fallback label.“1 Conversions” reads awkwardly. Singularize non-currency labels when value === 1 and guard against unmapped attributes to avoid “undefined”.
- const attributeLabel = WORKFLOW_ATTRIBUTE_LABELS[condition.attribute]; - const valueFormatted = isCurrency + const attributeLabelRaw = + WORKFLOW_ATTRIBUTE_LABELS[condition.attribute] ?? "Metric"; + const attributeLabel = + !isCurrency && + condition.value === 1 && + attributeLabelRaw.endsWith("s") + ? attributeLabelRaw.slice(0, -1) + : attributeLabelRaw; + const valueFormatted = isCurrency ? `${currencyFormatter(condition.value / 100)} in` : `${nFormatter(condition.value, { full: true })}`; - return `Earn ${rewardAmountFormatted} after generating ${valueFormatted} ${attributeLabel}`; + return `Earn ${rewardAmountFormatted} after generating ${valueFormatted} ${attributeLabel}`;
6-25
: Add unit tests for name generation edge cases.Cover: (a) no condition, (b) currency attrs (Revenue/Commissions) with non-integer dollars, (c) non-currency attrs with 1 vs >1, (d) unknown attribute fallback.
I can scaffold tests for these cases—want me to open a follow-up PR?
apps/web/ui/partners/bounties/bounty-logic.tsx (2)
72-76
: Improve readability of non-currency amounts (thousands separators).Format counts for easier scanning.
- : value + : Intl.NumberFormat("en-US").format(value as number)
103-113
: Use number input with appropriate step for better UX/validation.Enables numeric keypad on mobile and native constraints.
<input + type="number" + inputMode={isCurrency ? "decimal" : "numeric"} className={cn( "block w-full rounded-md border-neutral-300 px-1.5 py-1 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:outline-none focus:ring-neutral-500 sm:w-32 sm:text-sm", isCurrency ? "pl-4 pr-12" : "pr-7", )} {...register("performanceCondition.value", { required: true, setValueAs: (value: string) => (value === "" ? undefined : +value), - min: 0, + min: 0, onChange: handleMoneyInputChange, })} + step={isCurrency ? "0.01" : "1"} />apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (2)
177-191
: Improve file item accessibility and key stability.Use a stable key and avoid redundant/meaningless img alt.
- {submission.files!.map((file, idx) => ( + {submission.files!.map((file, idx) => ( <a - key={idx} + key={file.url ?? idx} className="border-border-subtle hover:border-border-default group relative flex size-14 items-center justify-center rounded-md border bg-white" target="_blank" href={file.url} rel="noopener noreferrer" > <div className="relative size-full overflow-hidden rounded-md"> - <img src={file.url} alt="object-cover" /> + <img src={file.url} alt="" /> </div>
264-270
: Use type="button" (no form).This isn’t inside a form; avoid accidental submits if nesting changes.
- <Button - type="submit" + <Button + type="button" variant="primary" text="Approve" loading={isApprovingBountySubmission} onClick={() => setShowApproveBountySubmissionModal(true)} />
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
💡 Knowledge Base configuration:
- MCP integration is disabled by default for public repositories
- Jira integration is disabled by default for public repositories
- Linear integration is disabled by default for public repositories
You can enable these sources in your CodeRabbit configuration.
📒 Files selected for processing (16)
apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
(1 hunks)apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts
(1 hunks)apps/web/app/(ee)/api/bounties/route.ts
(1 hunks)apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx
(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx
(1 hunks)apps/web/lib/actions/partners/approve-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/create-bounty-submission.ts
(1 hunks)apps/web/lib/actions/partners/reject-bounty-submission.ts
(1 hunks)apps/web/lib/api/audit-logs/schemas.ts
(3 hunks)apps/web/lib/api/bounties/generate-bounty-name.ts
(1 hunks)apps/web/lib/api/workflows/execute-award-bounty-action.ts
(1 hunks)apps/web/lib/zod/schemas/partner-profile.ts
(3 hunks)apps/web/ui/partners/bounties/bounty-logic.tsx
(1 hunks)packages/prisma/schema/bounty.prisma
(1 hunks)
🚧 Files skipped from review as they are similar to previous changes (8)
- apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx
- apps/web/app/(ee)/api/bounties/[bountyId]/route.ts
- apps/web/lib/api/workflows/execute-award-bounty-action.ts
- apps/web/lib/actions/partners/reject-bounty-submission.ts
- apps/web/app/(ee)/api/cron/bounties/notify-partners/route.ts
- apps/web/lib/actions/partners/create-bounty-submission.ts
- apps/web/app/(ee)/api/bounties/route.ts
- apps/web/lib/zod/schemas/partner-profile.ts
🧰 Additional context used
🧠 Learnings (5)
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.
Applied to files:
apps/web/lib/actions/partners/approve-bounty-submission.ts
apps/web/lib/api/audit-logs/schemas.ts
📚 Learning: 2025-08-26T14:32:33.851Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/actions/partners/create-bounty-submission.ts:105-112
Timestamp: 2025-08-26T14:32:33.851Z
Learning: Non-performance bounties are required to have submissionRequirements. In create-bounty-submission.ts, it's appropriate to let the parsing fail if submissionRequirements is null for non-performance bounties, as this indicates a data integrity issue that should be caught.
Applied to files:
apps/web/lib/actions/partners/approve-bounty-submission.ts
📚 Learning: 2025-07-30T15:25:13.936Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx:56-66
Timestamp: 2025-07-30T15:25:13.936Z
Learning: In apps/web/ui/partners/rewards/add-edit-reward-sheet.tsx, the form schema uses partial condition objects to allow users to add empty/unconfigured condition fields without type errors, while submission validation uses strict schemas to ensure data integrity. This two-stage validation pattern improves UX by allowing progressive completion of complex forms.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx
📚 Learning: 2025-07-30T15:29:54.131Z
Learnt from: TWilson023
PR: dubinc/dub#2673
File: apps/web/ui/partners/rewards/rewards-logic.tsx:268-275
Timestamp: 2025-07-30T15:29:54.131Z
Learning: In apps/web/ui/partners/rewards/rewards-logic.tsx, when setting the entity field in a reward condition, dependent fields (attribute, operator, value) should be reset rather than preserved because different entities (customer vs sale) have different available attributes. Maintaining existing fields when the entity changes would create invalid state combinations and confusing UX.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx
apps/web/ui/partners/bounties/bounty-logic.tsx
📚 Learning: 2025-08-26T15:03:05.381Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/ui/partners/bounties/bounty-logic.tsx:88-96
Timestamp: 2025-08-26T15:03:05.381Z
Learning: In bounty forms, currency values are stored in cents in the backend but converted to dollars when loaded into forms, and converted back to cents when saved. The form logic works entirely with dollar amounts. Functions like generateBountyName that run during save logic receive cent values and need to divide by 100, but display logic within the form should format dollar values directly.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx
apps/web/ui/partners/bounties/bounty-logic.tsx
🧬 Code graph analysis (7)
apps/web/lib/api/bounties/generate-bounty-name.ts (4)
apps/web/lib/types.ts (1)
WorkflowCondition
(542-542)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter
(1-13)apps/web/lib/api/workflows/utils.ts (1)
isCurrencyAttribute
(3-4)apps/web/lib/zod/schemas/workflows.ts (1)
WORKFLOW_ATTRIBUTE_LABELS
(16-24)
apps/web/lib/actions/partners/approve-bounty-submission.ts (4)
apps/web/lib/partners/create-partner-commission.ts (1)
createPartnerCommission
(24-321)apps/web/lib/api/audit-logs/record-audit-log.ts (1)
recordAuditLog
(43-63)apps/web/lib/zod/schemas/bounties.ts (1)
BountySubmissionSchema
(81-92)packages/email/src/templates/bounty-approved.tsx (1)
BountyApproved
(18-94)
apps/web/app/(ee)/api/bounties/[bountyId]/submissions/route.ts (5)
apps/web/app/(ee)/api/bounties/[bountyId]/route.ts (1)
GET
(22-45)apps/web/app/(ee)/api/bounties/route.ts (1)
GET
(28-70)apps/web/lib/zod/schemas/bounties.ts (2)
getBountySubmissionsQuerySchema
(140-149)BountySubmissionExtendedSchema
(94-123)apps/web/lib/api/bounties/get-partners-with-bounty-submission.ts (1)
getPartnersWithBountySubmission
(13-107)apps/web/lib/types.ts (1)
BountySubmissionsQueryFilters
(562-564)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx (10)
apps/web/lib/types.ts (3)
BountyExtendedProps
(531-531)BountyProps
(529-529)BountySubmissionRequirement
(539-540)apps/web/lib/zod/schemas/bounties.ts (1)
createBountySchema
(26-44)packages/ui/src/card-selector.tsx (2)
CardSelectorOption
(8-13)CardSelector
(26-108)apps/web/lib/swr/use-workspace.ts (1)
useWorkspace
(6-45)apps/web/lib/swr/use-api-mutation.ts (1)
useApiMutation
(36-123)apps/web/lib/api/workflows/utils.ts (1)
isCurrencyAttribute
(3-4)apps/web/lib/zod/schemas/workflows.ts (1)
workflowConditionSchema
(59-63)apps/web/ui/shared/amount-input.tsx (1)
AmountInput
(19-87)apps/web/ui/partners/bounties/bounty-logic.tsx (1)
BountyLogic
(23-87)apps/web/ui/partners/groups/groups-multi-select.tsx (1)
GroupsMultiSelect
(31-246)
apps/web/ui/partners/bounties/bounty-logic.tsx (6)
apps/web/lib/zod/schemas/workflows.ts (1)
WORKFLOW_ATTRIBUTES
(9-14)apps/web/lib/types.ts (1)
WorkflowConditionAttribute
(544-544)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/add-edit-bounty-sheet.tsx (1)
useAddEditBountyForm
(52-52)apps/web/ui/shared/inline-badge-popover.tsx (3)
InlineBadgePopover
(33-76)InlineBadgePopoverMenu
(85-174)InlineBadgePopoverContext
(25-31)apps/web/lib/api/workflows/utils.ts (1)
isCurrencyAttribute
(3-4)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter
(1-13)
apps/web/lib/api/audit-logs/schemas.ts (1)
apps/web/lib/zod/schemas/bounties.ts (2)
BountySchema
(59-70)BountySubmissionSchema
(81-92)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (7)
apps/web/lib/types.ts (1)
BountySubmissionProps
(535-537)apps/web/lib/swr/use-workspace.ts (1)
useWorkspace
(6-45)apps/web/ui/partners/reject-bounty-submission-modal.tsx (1)
useRejectBountySubmissionModal
(169-192)apps/web/lib/actions/partners/approve-bounty-submission.ts (1)
approveBountySubmissionAction
(20-118)apps/web/ui/partners/partner-info-section.tsx (1)
PartnerInfoSection
(13-119)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)
BOUNTY_SUBMISSION_STATUS_BADGES
(3-22)packages/utils/src/functions/currency-formatter.ts (1)
currencyFormatter
(1-13)
🪛 Biome (2.1.2)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
[error] 204-204: Missing key property for this element in iterable.
The order of the items may change, and having a key can help React identify which item was moved.
Check the React documentation.
(lint/correctness/useJsxKeyInIterable)
⏰ 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/api/bounties/generate-bounty-name.ts (2)
24-25
: Overall: clean, readable, and side-effect free.Logic is straightforward, early-return is clear, and the output string reads well for the main cases.
1-4
: Imports and schema contracts verified
nFormatter and currencyFormatter are re-exported from @dub/utils root; workflowConditionSchema defines value as a number; TypeScript enforces that WORKFLOW_ATTRIBUTE_LABELS covers every WorkflowConditionAttribute.apps/web/lib/api/audit-logs/schemas.ts (2)
81-87
: LGTM: added bounty actions to audit action enum.Action surface looks consistent with existing events.
171-181
: LGTM: target shapes for bounty and bounty_submission.Using full schemas provides rich metadata; aligns with recorders.
packages/prisma/schema/bounty.prisma (1)
23-25
: Schema consistency: is bounty.name optional?Prisma has
name String
(required) but Zod BountySchema allowsname: string | null
. Please reconcile (make Prisma optional or tighten Zod).Would you like a patch aligning both sides (and a migration) to your intended constraint?
Summary by CodeRabbit
New Features
Notifications
Integrations
UI / UX
Tests
Chores