-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Improve bounty submissions review flow #2986
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.
💡 Enable Vercel Agent with $100 free credit for automated AI reviews |
WalkthroughAdds optional next/previous navigation (buttons + Arrow keys) and A/R shortcuts to the bounty submission details sheet, wires onNext/onPrevious through the submissions table navigation, integrates onNext into approve/reject success flows, shifts submissions sorting to completedAt, and surfaces per-bounty submissions aggregates. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant Table as SubmissionsTable
participant Sheet as DetailsSheet
participant Modal as Approve/RejectModal
participant API
User->>Table: open submission (submissionId in query)
Table->>Sheet: render(submission, onNext?, onPrevious?)
Note right of Sheet #dbeafe: Header chevrons + shortcuts ← / → / A / R
alt Approve (A or click)
User->>Sheet: trigger approve
Sheet->>Modal: open approve modal (confirmShortcut "a")
User->>Modal: confirm
Modal->>API: approve mutation
API-->>Modal: success
Modal->>Sheet: onSuccess
Sheet->>Table: call onNext() if provided else close sheet
end
alt Reject (R or click)
User->>Sheet: trigger reject
Sheet->>Modal: open reject modal
User->>Modal: confirm
Modal->>API: reject mutation
API-->>Modal: success
Modal->>Sheet: onSuccess (invoke onReject if provided)
Sheet->>Table: call onNext() if provided
end
alt Navigate ← / →
User->>Sheet: onPrevious/onNext
Sheet->>Table: navigateToSubmission(direction)
Table->>Table: update submissionId in query (wrap at ends)
Table->>Sheet: re-render with new submission
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
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
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/bounties/bounty-action-button.tsx (1)
93-106: Show tooltip and actually disable the delete action.Currently only disabledTooltip is set; item remains clickable. Prevent accidental deletion attempts and reduce confusion.
<MenuItem as={Command.Item} icon={Trash} variant="danger" - onSelect={() => { - setIsOpen(false); - setShowDeleteModal(true); - }} + disabled={totalSubmissions > 0} + onSelect={() => { + if (totalSubmissions > 0) return; + setIsOpen(false); + setShowDeleteModal(true); + }} disabledTooltip={ totalSubmissions > 0 ? "Bounties with submissions cannot be deleted." : undefined } >apps/web/lib/zod/schemas/bounties.ts (1)
31-37: Schema/backend mismatch for sortBy options confirmed.The schema permits sorting by "leads", "conversions", "saleAmount", and "commissions", but the backend only supports two actual database fields: "completedAt" and "performanceCount". Requests for the unsupported fields silently fall through to sorting by "performanceCount", causing unpredictable sort behavior.
Update
BOUNTY_SUBMISSIONS_SORT_BY_COLUMNSto match backend support:
- Keep only
"completedAt"and remove the four unsupported fields, OR- Implement backend orderBy logic for each field with proper DB columns/joins.
🧹 Nitpick comments (5)
apps/web/lib/api/bounties/get-bounty-submissions.ts (2)
21-21: Ensure undefined status is omitted, not treated as a filter.Prisma typically ignores undefined in where, but be explicit to avoid accidental narrowing.
- status, + ...(status ? { status } : {}),
43-55: Avoid spreading entire programEnrollment into partner payload.This can leak unintended fields and diverge from BountySubmissionExtendedSchema (which expects only specific partner fields including groupId/status).
- partner: { - ...partner, - ...programEnrollment, - id: partner.id, - status: programEnrollment?.status, - }, + partner: { + ...partner, + id: partner.id, + groupId: programEnrollment?.groupId ?? null, + status: programEnrollment?.status ?? null, + },apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx (1)
34-42: Load-safety for counts.If the hook is still loading, totalSubmissions is 0 and the menu briefly appears enabled. Consider passing an isLoading flag from the hook to gate the menu, or render a loading state until counts arrive.
apps/web/lib/zod/schemas/bounties.ts (1)
111-112: booleanQuerySchema default.Defaulting with "false" (string) is fine due to transform, but can be confusing. Consider default(false) post-transform for clarity.
apps/web/app/(ee)/api/bounties/route.ts (1)
48-95: Counting approach is correct; consider pre-indexing for O(n) aggregation.Filtering the full groupBy array per bounty is O(B×S). Pre-index by bountyId once to reduce to O(B+S).
- const aggregateSubmissionsCountForBounty = (bountyId: string) => { - if (!allBountiesSubmissionsCount) { - return null; - } - const bountySubmissions = allBountiesSubmissionsCount.filter( - (s) => s.bountyId === bountyId, - ); + const countsByBounty = allBountiesSubmissionsCount + ? allBountiesSubmissionsCount.reduce<Record<string, typeof allBountiesSubmissionsCount>>( + (acc, row) => { + (acc[row.bountyId] ||= []).push(row); + return acc; + }, + {}, + ) + : null; + + const aggregateSubmissionsCountForBounty = (bountyId: string) => { + if (!countsByBounty) return null; + const bountySubmissions = countsByBounty[bountyId] ?? []; const total = bountySubmissions.reduce( (sum, s) => sum + s._count.status, 0, );
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (9)
apps/web/app/(ee)/api/bounties/route.ts(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx(10 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-list.tsx(1 hunks)apps/web/lib/api/bounties/get-bounty-submissions.ts(2 hunks)apps/web/lib/api/bounties/get-bounty-with-details.ts(1 hunks)apps/web/lib/zod/schemas/bounties.ts(4 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-08-25T17:39:38.965Z
Learnt from: devkiran
PR: dubinc/dub#2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx:45-56
Timestamp: 2025-08-25T17:39:38.965Z
Learning: In the bounty system, each partner can only submit to the same bounty once. This means totalSubmissions (pending + approved + rejected) equals the number of unique partners who have submitted, making UI text like "X of Y partners completed" accurate when using totalSubmissions.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/bounties/route.ts (2)
apps/web/lib/zod/schemas/bounties.ts (2)
getBountiesQuerySchema(109-112)BountyListSchema(115-123)apps/web/lib/zod/schemas/workflows.ts (1)
WORKFLOW_ATTRIBUTE_TRIGGER(19-30)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (2)
packages/prisma/client.ts (1)
BountySubmissionStatus(6-6)packages/ui/src/timestamp-tooltip.tsx (1)
TimestampTooltip(28-50)
apps/web/lib/zod/schemas/bounties.ts (1)
apps/web/lib/zod/schemas/misc.ts (1)
booleanQuerySchema(24-29)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (18)
apps/web/lib/zod/schemas/bounties.ts (1)
178-185: Default sort changed to completedAt asc — verify UX.Ascending completedAt may surface older completions first and nulls (for submitted) ahead of completed, depending on DB null ordering. Confirm this matches table expectations.
Would you like me to add explicit nulls ordering (e.g., nulls: "last" if supported) to avoid submitted items bubbling to the top?
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-list.tsx (1)
22-25: LGTM — conditional query with includeSubmissionsCount.Key construction via URLSearchParams is correct and keeps SWR key stable.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx (1)
30-33: Computed ready-for-review count looks good.Using the “submitted” status directly aligns with “awaiting review”.
apps/web/lib/api/bounties/get-bounty-with-details.ts (1)
61-63: LGTM — removed submissions count from details.Consistent with new includeSubmissionsCount path in list route.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx (3)
55-60: Badge behavior reads well.Only show “awaiting review” when submitted > 0; good signal.
91-99: Totals logic aligns with new submissionsCountData.Using total for numerator and computed group-based denominator is consistent; handles “All” nicely.
130-132: Copy: concise and clear.Badge copy and formatting are good.
apps/web/app/(ee)/api/bounties/route.ts (2)
81-88: Intentional exclusion of rejected. Verify all consumers expect this.Totals include submitted+approved only. UI components (cards/info) align, but ensure any “All” comparisons or analytics don’t expect rejected in totals.
120-128: Schema parse + conditional attachment is solid.Nice use of BountyListSchema and optional submissionsCountData.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (9)
6-9: LGTM! New imports support the submissions count and status filtering features.The additions properly import the necessary types and hooks for the enhanced navigation and filtering functionality.
Also applies to: 36-36
92-93: LGTM! Clean integration of the submissions count hook.Properly typed and used later for accurate row count calculation.
104-112: LGTM! Proper query parameter handling.Correctly excludes
submissionIdfrom the API query since it's used only for UI state (details sheet), not data filtering. The new parameters align with the updated API.
141-172: Excellent implementation of bidirectional navigation with wrapping!The logic correctly handles edge cases:
- Guard clauses prevent errors when data is unavailable
- Wrapping at boundaries provides seamless navigation (next from last → first, previous from first → last)
- URL-based state management integrates cleanly with the existing useEffect
This enables the keyboard shortcuts (A/R) mentioned in the PR objectives while maintaining a smooth UX.
63-63: LGTM! Column visibility correctly reflects the completedAt migration.Switching from
createdAttocompletedAtprovides more meaningful timestamps for tracking when submissions were finished, not just initiated.Also applies to: 69-70
226-249: LGTM! Proper null handling and adaptive header text.The defensive check on Line 233 prevents rendering issues if
completedAtis null. The header text appropriately distinguishes between performance bounties ("Completed") and others ("Submitted").
351-356: LGTM! Sortable columns properly reflect the completedAt migration.The conditional inclusion of performance metric columns ensures sorting options match the displayed data.
373-373: LGTM! Row count and navigation callbacks properly integrated.
- Line 373: Safe optional chaining with fallback ensures accurate pagination even when count data is unavailable
- Lines 387-388: Navigation callbacks enable bidirectional traversal from the details sheet
Also applies to: 387-388
87-88: No issues found. The default status value is valid.The verification confirms that
"submitted"is a valid enum value in theBountySubmissionStatusenum as defined inpackages/prisma/schema/bounty.prisma. The type assertion is correct and will not cause runtime issues.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx
Outdated
Show resolved
Hide resolved
|
Deployment failed with the following error: |
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
🧹 Nitpick comments (11)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1)
10-11: UnusedsearchParamsparameter.The
searchParamsparameter is destructured but never used in the handler body. Based on the PR context mentioningincludeSubmissionsCountbeing introduced in bounties APIs, this parameter may have been added to support that feature.Please either:
- Remove the unused parameter if it's not needed, or
- Implement the expected functionality (e.g.,
includeSubmissionsCountquery parameter support)export const GET = withPartnerProfile( - async ({ partner, params, searchParams }) => { + async ({ partner, params }) => {Alternatively, if
includeSubmissionsCountshould be supported here, implement it similar to other bounties endpoints in this PR.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (1)
15-16: Simplify the boolean expression.The ternary operator returning boolean literals is redundant since the expression already evaluates to a boolean.
Apply this diff to simplify:
- const expiredBounty = - bounty.endsAt && new Date(bounty.endsAt) < new Date() ? true : false; + const expiredBounty = !!bounty.endsAt && new Date(bounty.endsAt) < new Date();apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx (6)
14-23: Type-safety for tab ids.
Derive a TabId type from tabs to keep state and usages in sync as tabs evolve.Add near the tabs declaration:
type TabId = (typeof tabs)[number]["id"];
26-26: Use the derived TabId for state to avoid drift.
Keeps state constrained to the tabs’ ids.- const [activeTab, setActiveTab] = useState<"active" | "expired">("active"); + const [activeTab, setActiveTab] = useState<TabId>("active");
29-44: Harden date handling (Invalid Date/UTC edges) and avoid duplicate parsing.
Parse once to ms and guard against NaN; this also avoids repeated Date allocations.Please confirm the shape of bounty.endsAt (ISO with timezone vs date-only like YYYY-MM-DD). If date-only, we may need to interpret it in a specific timezone or as end-of-day to avoid off-by-hours.
Add a small helper near the top of the file:
const isExpiredAt = ( endsAt: string | null | undefined, nowMs: number, ): boolean => { if (!endsAt) return false; const ms = Date.parse(endsAt); return Number.isFinite(ms) && ms <= nowMs; };Then update the memo:
- const filteredBounties = useMemo(() => { + const filteredBounties = useMemo(() => { if (!bounties) return []; - - const now = new Date(); - return bounties.filter((bounty) => { - const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= now; + const nowMs = Date.now(); + return bounties.filter(({ endsAt }) => { + const isExpired = isExpiredAt(endsAt, nowMs); if (activeTab === "active") { return !isExpired; } else { return isExpired; } }); }, [bounties, activeTab]);
45-59: DRY up expiry logic in counts to match filtering.
Reuse the same helper to keep behavior consistent and avoid parsing twice.- const tabCounts = useMemo(() => { + const tabCounts = useMemo(() => { if (!bounties) return { active: 0, expired: 0 }; - - const now = new Date(); - return bounties.reduce( - (counts, bounty) => { - const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= now; + const nowMs = Date.now(); + return bounties.reduce( + (counts, { endsAt }) => { + const isExpired = isExpiredAt(endsAt, nowMs); counts[isExpired ? "expired" : "active"]++; return counts; }, { active: 0, expired: 0 }, ); }, [bounties]);
62-92: Make the toggle an accessible tablist and add Arrow key navigation.
Improves screen reader and keyboard UX without changing visuals.- <div className="mb-6 grid grid-cols-2 gap-2"> + <div + role="tablist" + aria-label="Bounties" + className="mb-6 grid grid-cols-2 gap-2" + onKeyDown={(e) => { + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault(); + const i = tabs.findIndex((t) => t.id === activeTab); + const next = + e.key === "ArrowLeft" + ? (i - 1 + tabs.length) % tabs.length + : (i + 1) % tabs.length; + setActiveTab(tabs[next].id); + document + .getElementById(`bounties-tab-${tabs[next].id}`) + ?.focus(); + } + }} + > {tabs.map((tab) => { const isActive = activeTab === tab.id; return ( <button + id={`bounties-tab-${tab.id}`} + role="tab" + aria-selected={isActive} + aria-controls="bounties-panel" + tabIndex={isActive ? 0 : -1} key={tab.id} type="button" className={cn( "border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100", isActive ? "border-black ring-1 ring-black" : "hover:bg-bg-muted", )} onClick={() => setActiveTab(tab.id)} >
93-131: Wrap content in a tabpanel and simplify the condition.
Improves a11y semantics and readability.- {filteredBounties?.length !== 0 || isLoading ? ( - <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"> + <div + role="tabpanel" + id="bounties-panel" + aria-labelledby={`bounties-tab-${activeTab}`} + > + {filteredBounties.length > 0 || isLoading ? ( + <div className="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3"> {filteredBounties?.length ? filteredBounties?.map((bounty) => ( <PartnerBountyCard key={bounty.id} bounty={bounty} /> )) : Array.from({ length: 3 }, (_, index) => ( <PartnerBountyCardSkeleton key={index} /> ))} </div> ) : ( <AnimatedEmptyState title="No bounties to collect" @@ /> - )} + )} + </div>apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (3)
48-50: Post-approve navigation and reject callback wiring.
PassingonNextintouseRejectBountySubmissionModalasonRejectis appropriate; invokingonNexton approve success is consistent. Be aware that ifonNextdoes not close the current sheet, you may end up with two sheets; consider closing first then navigating if that’s a risk in callers.Also applies to: 59-61
154-180: Add accessible labels to icon-only header buttons.
The nav chevrons and close button are icon-only; addaria-label(and optionallytitle) for screen readers.- <Button + <Button + aria-label="Previous submission" + title="Previous (←)" type="button" disabled={!onPrevious} onClick={onPrevious} variant="secondary" className="size-9 rounded-l-lg rounded-r-none p-0" icon={<ChevronLeft className="size-3.5" />} /> - <Button + <Button + aria-label="Next submission" + title="Next (→)" type="button" disabled={!onNext} onClick={onNext} variant="secondary" className="-ml-px size-9 rounded-l-none rounded-r-lg p-0" icon={<ChevronRight className="size-3.5" />} /> ... - <Button + <Button + aria-label="Close" + title="Close" variant="outline" icon={<X className="size-5" />} className="h-auto w-fit p-1" />
399-433: Approve button should betype="button"; shortcut labels look good.
Usingtype="submit"outside a form can cause accidental submits if nesting changes. Switch tobutton.- <Button - type="submit" + <Button + type="button" variant="primary" text="Approve" shortcut="A" loading={isApprovingBountySubmission} onClick={() => setShowApproveBountySubmissionModal(true)}
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (5)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx(9 hunks)apps/web/ui/partners/partner-application-sheet.tsx(1 hunks)
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-18T17:03:06.200Z
Learnt from: TWilson023
PR: dubinc/dub#2858
File: apps/web/ui/partners/partner-application-sheet.tsx:262-266
Timestamp: 2025-09-18T17:03:06.200Z
Learning: The useKeyboardShortcut hook with context options like {sheet: true, modal: true} requires ALL specified contexts to be present simultaneously. The shortcut will only trigger when both existingSheetBackdrop and existingModalBackdrop are present, using strict equality matching in the implementation.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
🧬 Code graph analysis (3)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (4)
apps/web/app/(ee)/api/partner-profile/programs/[programId]/route.ts (1)
GET(9-39)apps/web/lib/auth/partner.ts (1)
withPartnerProfile(34-132)apps/web/lib/api/programs/get-program-enrollment-or-throw.ts (1)
getProgramEnrollmentOrThrow(6-67)apps/web/lib/zod/schemas/partner-profile.ts (1)
PartnerBountySchema(142-155)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (1)
apps/web/ui/partners/reject-bounty-submission-modal.tsx (1)
useRejectBountySubmissionModal(172-196)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2)
apps/web/lib/types.ts (1)
PartnerBountyProps(589-589)apps/web/ui/partners/bounties/claim-bounty-modal.tsx (1)
useClaimBountyModal(678-693)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build
🔇 Additional comments (10)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (4)
24-30: Good accessibility and UX implementation.The disabled attribute and conditional styling properly handle the expired state with appropriate visual feedback.
37-43: LGTM!The expired badge overlay provides clear visual feedback with appropriate positioning and styling. The non-null assertion is safe given the
expiredBountyguard.
55-58: Nice UX improvement with conditional tense.Displaying past tense for expired bounties enhances clarity and provides better contextual information to users.
73-80: Verify the UX impact of hiding submission status for expired bounties.When a bounty expires, the submission status section is completely hidden, even if the user has an existing submission (pending review, approved, or rejected). This might prevent users from seeing important information about their past submissions.
Consider whether users should still be able to view their submission status for expired bounties, particularly for approved or rejected submissions.
apps/web/ui/partners/partner-application-sheet.tsx (1)
48-68: No changes needed—input focus protection already implemented.The
useKeyboardShortcutimplementation inpackages/ui/src/hooks/use-keyboard-shortcut.tsx(lines 68–69) already checks if focus is on INPUT or TEXTAREA elements and returns early before executing the callback. This prevents arrow key shortcuts from interfering with text input navigation in the partner application sheet or anywhere else in the codebase.apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx (1)
7-8: LGTM on utilities and hooks imports.
No issues spotted.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (4)
15-24: Imports look good; keyboard hook correctly added.
No issues with adding Chevron icons anduseKeyboardShortcut.
32-37: Prop threading for onNext/onPrevious is consistent.
Types and threading into content are correct.Also applies to: 39-44
89-99: Arrow key shortcuts may hijack caret navigation in inputs.
IfuseKeyboardShortcutdoes not ignore editable targets (inputs/textareas/contenteditable), Left/Right could block caret movement in the Reward field. Please confirm and, if needed, scope the handler to non-editable contexts or use an API option to ignore inputs. Based on learnings.Would you verify whether
useKeyboardShortcutalready ignores key events from form fields? If not, I can propose a guarded handler that early-returns whene.targetis editable.Also applies to: 100-110
464-472: Hook API changes read well.
ExtendinguseBountySubmissionDetailsSheetto acceptonNext/onPreviousand omitting them from the child props is clean and avoids prop collisions.
...b.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
Show resolved
Hide resolved
|
Deployment failed with the following error: |
|
@coderabbitai full review |
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 7
♻️ Duplicate comments (2)
apps/web/lib/api/bounties/get-bounty-submissions.ts (1)
35-36: Sorting still ignores other schema-allowed sortBy values. Map all, not just completedAt/performanceCount.Requests for "leads", "conversions", "saleAmount", "commissions" won’t sort as expected. Provide an explicit mapping to real columns/relations.
- orderBy: { - [sortBy === "completedAt" ? "completedAt" : "performanceCount"]: - sortOrder, - }, + orderBy: + sortBy === "completedAt" + ? { completedAt: sortOrder } + : sortBy === "performanceCount" + ? { performanceCount: sortOrder } + : sortBy === "commissions" + ? { commission: { earnings: sortOrder } } // or "amount" if that’s the correct field + : sortBy === "saleAmount" + ? { commission: { amount: sortOrder } } + : sortBy === "leads" + ? { leads: sortOrder } // ensure this column exists or join/aggregate accordingly + : sortBy === "conversions" + ? { conversions: sortOrder } // ensure this column exists or join/aggregate accordingly + : { completedAt: sortOrder }, // sane defaultIf some fields aren’t on BountySubmission, either join/aggregate them here or remove them from the zod enum to keep schema and DB in sync. Based on learnings
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (1)
74-75: Conflict: "A" opens modal and also confirms it → double-trigger risk.With
confirmShortcut: "a"(scoped{ sheet: true, modal: true }) and a sheet-level"a"binding that opens the modal with{ sheet: true }, pressing "A" while the confirm modal is open can trigger both handlers simultaneously. This causes double-triggering and potential duplicate actions.Based on learnings.
Apply one of these fixes:
Option A (preferred): Change the modal confirm to Enter:
confirmText: "Approve", - confirmShortcut: "a", - confirmShortcutOptions: { sheet: true, modal: true }, + confirmShortcut: "Enter", + confirmShortcutOptions: { modal: true },Option B: Change the sheet-level opener to a different key (e.g., Shift+A) or add a guard to check if the modal is already open before triggering.
Also applies to: 111-119
🧹 Nitpick comments (13)
apps/web/app/(ee)/api/bounties/route.ts (2)
48-95: Constrain groupBy to fetched bountyIds; guard partner group filter against null.
- Today, groupBy scans all submissions in the program. For large programs, filter by the current bounty ids to cut I/O and CPU.
- Avoid passing a possibly null groupId into groups.some; compute it and include the clause only when present.
Apply this refactor:
- const [bounties, allBountiesSubmissionsCount] = await Promise.all([ - prisma.bounty.findMany({ + const allowedGroupId = + programEnrollment?.groupId ?? + programEnrollment?.program.defaultGroupId; + + const bounties = await prisma.bounty.findMany({ where: { programId, // Filter only bounties the specified partner is eligible for ...(programEnrollment && { OR: [ { groups: { none: {}, }, }, - { - groups: { - some: { - groupId: - programEnrollment.groupId || - programEnrollment.program.defaultGroupId, - }, - }, - }, + ...(allowedGroupId + ? [ + { + groups: { + some: { + groupId: allowedGroupId, + }, + }, + }, + ] + : []), ], }), }, include: { groups: { select: { groupId: true, }, }, }, - }), - includeSubmissionsCount - ? prisma.bountySubmission.groupBy({ - by: ["bountyId", "status"], - where: { - programId, - status: { - in: ["submitted", "approved"], - }, - }, - _count: { - status: true, - }, - }) - : null, - ]); + }); + + const allBountiesSubmissionsCount = includeSubmissionsCount + ? await prisma.bountySubmission.groupBy({ + by: ["bountyId", "status"], + where: { + programId, + bountyId: { in: bounties.map((b) => b.id) }, + status: { in: ["submitted", "approved"] }, + }, + _count: { status: true }, + }) + : null;Add/confirm an index to support the query:
- Prisma schema: @@index([programId, bountyId, status], name: "idx_bountySubmission_program_bounty_status")
96-118: Avoid O(B*S) by pre-aggregating into a Map.Current approach filters and reduces for every bounty. Precompute once, then O(1) lookups.
Apply:
- const aggregateSubmissionsCountForBounty = (bountyId: string) => { - if (!allBountiesSubmissionsCount) { - return null; - } - const bountySubmissions = allBountiesSubmissionsCount.filter( - (s) => s.bountyId === bountyId, - ); - const total = bountySubmissions.reduce( - (sum, s) => sum + s._count.status, - 0, - ); - const submitted = - bountySubmissions.find((s) => s.status === "submitted")?._count - .status ?? 0; - const approved = - bountySubmissions.find((s) => s.status === "approved")?._count.status ?? - 0; - return { - total, - submitted, - approved, - }; - }; + const countsByBounty = new Map< + string, + { total: number; submitted: number; approved: number } + >(); + if (allBountiesSubmissionsCount) { + for (const row of allBountiesSubmissionsCount) { + const cur = + countsByBounty.get(row.bountyId) ?? { + total: 0, + submitted: 0, + approved: 0, + }; + cur.total += row._count.status; + if (row.status === "submitted") cur.submitted += row._count.status; + else if (row.status === "approved") cur.approved += row._count.status; + countsByBounty.set(row.bountyId, cur); + } + } + + const aggregateSubmissionsCountForBounty = (bountyId: string) => { + if (!allBountiesSubmissionsCount) return null; + return ( + countsByBounty.get(bountyId) ?? { + total: 0, + submitted: 0, + approved: 0, + } + ); + };apps/web/ui/partners/reject-bounty-submission-modal.tsx (1)
57-57: Use optional chaining for cleaner callback invocation.Simplify to avoid a ternary.
- onReject ? onReject() : null; + onReject?.();apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx (2)
287-290: Also return loading state from the hook to avoid “New” badge when count is 0.Destructure isLoading from usePartnerProgramBounties so you can pass undefined while loading/not enrolled. This prevents showing “New” when there are actually 0 active bounties.
- const { bountiesCount } = usePartnerProgramBounties({ + const { bountiesCount, isLoading: bountiesLoading } = usePartnerProgramBounties({ enabled: isEnrolledProgramPage, });Then, couple with the change below (Line 311). Verify visually that badges render as intended across: loading, 0, small (<100), large (>=100).
311-312: Pass undefined while loading to align badge semantics.So NAV_AREAS can treat undefined = “New”, 0 = no badge.
- programBountiesCount: bountiesCount.active, + programBountiesCount: bountiesLoading ? undefined : bountiesCount.active,Additionally (outside this diff), adjust NAV_AREAS badge logic to only show “New” when programBountiesCount is undefined:
badge: programBountiesCount === undefined ? "New" : programBountiesCount > 99 ? "99+" : programBountiesCount || undefinedapps/web/lib/swr/use-partner-program-bounties.ts (1)
29-39: Compute “now” once per memo; avoid new Date() per item and use getTime().Tiny perf/readability win for large lists.
- const bountiesCount = useMemo(() => { - if (!bounties) return { active: 0, expired: 0 }; - return bounties.reduce( - (counts, bounty) => { - const isExpired = bounty.endsAt && new Date(bounty.endsAt) < new Date(); - counts[isExpired ? "expired" : "active"]++; - return counts; - }, - { active: 0, expired: 0 }, - ); - }, [bounties]); + const bountiesCount = useMemo(() => { + if (!bounties) return { active: 0, expired: 0 }; + const now = Date.now(); + return bounties.reduce( + (counts, bounty) => { + const isExpired = + bounty.endsAt && new Date(bounty.endsAt).getTime() < now; + counts[isExpired ? "expired" : "active"]++; + return counts; + }, + { active: 0, expired: 0 }, + ); + }, [bounties]);apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (1)
25-30: Add aria-disabled for better a11y on disabled button.Button is disabled visually and functionally; aria-disabled improves screen reader clarity.
- <button + <button type="button" onClick={() => setShowClaimBountyModal(true)} disabled={expiredBounty} + aria-disabled={expiredBounty} className={cn( "border-border-subtle group relative flex cursor-pointer flex-col gap-5 overflow-hidden rounded-xl border bg-white p-5 text-left", expiredBounty ? "cursor-not-allowed" : "hover:border-border-default transition-all hover:shadow-lg", )} >Also applies to: 37-43, 55-58, 73-80
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx (1)
115-119: A11y: label the icon-only buttonAdd an accessible label and title.
- <Button + <Button type="button" className={buttonClassName || "w-auto px-1.5"} variant="secondary" icon={<ThreeDots className="h-4 w-4 shrink-0" />} + aria-label="Bounty actions" + title="Bounty actions" />apps/web/lib/api/bounties/get-bounty-with-details.ts (1)
81-96: Tighten types for groupsExplicitly type the parsed groups to avoid
anyand skip the null filter if JSON_ARRAY() default is guaranteed.- groups: bounty.groups.filter((group) => group !== null) ?? [], + groups: (bounty.groups as Array<{ id: string }> | null) ?? [],apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx (1)
51-63: A11y: Tabs should expose pressed/selected stateExpose ARIA to assistive tech.
<button key={tab.id} type="button" className={cn( "border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100", isActive ? "border-black ring-1 ring-black" : "hover:bg-bg-muted", )} + role="tab" + aria-selected={isActive} + aria-controls={`panel-${tab.id}`} onClick={() => setActiveTab(tab.id)} >apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx (1)
55-61: Prevent badge overlap with “Ended” badgeBoth badges render at left-2 top-2; they’ll overlap when a bounty ended but still has submitted items.
- <div className="absolute left-2 top-2 z-10"> + <div className="absolute left-2 top-8 z-10">Or conditionally hide submissions badge when ended.
apps/web/lib/zod/schemas/bounties.ts (1)
107-115: Consider making submissionsCountData non-optional with a defaultRemoves many optional chains in UI and ensures a stable shape.
-export const BountyListSchema = BountySchema.extend({ - submissionsCountData: z - .object({ - total: z.number().default(0), - submitted: z.number().default(0), - approved: z.number().default(0), - }) - .optional(), -}); +export const BountyListSchema = BountySchema.extend({ + submissionsCountData: z + .object({ + total: z.number().default(0), + submitted: z.number().default(0), + approved: z.number().default(0), + }) + .default({ total: 0, submitted: 0, approved: 0 }), +});apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (1)
249-249: Inconsistent column identifier naming.The column id is
"performanceCount"but it's conditionally added based onshowColumns.includes("performanceMetrics"). This naming mismatch creates unnecessary confusion.Apply this diff to align the naming:
- id: "performanceCount", + id: "performanceMetrics",
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (18)
apps/web/app/(ee)/api/bounties/route.ts(3 hunks)apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx(1 hunks)apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx(2 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx(9 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx(11 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx(3 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-list.tsx(1 hunks)apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx(1 hunks)apps/web/lib/api/bounties/get-bounty-submissions.ts(2 hunks)apps/web/lib/api/bounties/get-bounty-with-details.ts(1 hunks)apps/web/lib/swr/use-partner-program-bounties.ts(2 hunks)apps/web/lib/zod/schemas/bounties.ts(3 hunks)apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx(2 hunks)apps/web/ui/partners/partner-application-sheet.tsx(1 hunks)apps/web/ui/partners/reject-bounty-submission-modal.tsx(3 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-25T17:39:38.965Z
Learnt from: devkiran
PR: dubinc/dub#2736
File: apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx:45-56
Timestamp: 2025-08-25T17:39:38.965Z
Learning: In the bounty system, each partner can only submit to the same bounty once. This means totalSubmissions (pending + approved + rejected) equals the number of unique partners who have submitted, making UI text like "X of Y partners completed" accurate when using totalSubmissions.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsxapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
📚 Learning: 2025-09-18T17:03:06.200Z
Learnt from: TWilson023
PR: dubinc/dub#2858
File: apps/web/ui/partners/partner-application-sheet.tsx:262-266
Timestamp: 2025-09-18T17:03:06.200Z
Learning: The useKeyboardShortcut hook with context options like {sheet: true, modal: true} requires ALL specified contexts to be present simultaneously. The shortcut will only trigger when both existingSheetBackdrop and existingModalBackdrop are present, using strict equality matching in the implementation.
Applied to files:
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
🔇 Additional comments (14)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx (1)
17-17: LGTM! Styling improvement for page layout.The addition of bottom padding improves the visual spacing at the bottom of the page. The change is straightforward and follows Tailwind CSS conventions.
apps/web/ui/partners/partner-application-sheet.tsx (1)
48-68: Verified:useKeyboardShortcutcorrectly handles changing callbacks.The hook implementation in
packages/ui/src/hooks/use-keyboard-shortcut.tsxincludes thecallbackparameter in theuseCallbackdependency array (line 93). This ensures that whenonNextoronPreviousprops change, the memoized handler is recreated and the event listener is re-registered, preventing stale closures. The keyboard shortcut implementation is correct as-is.apps/web/app/(ee)/api/bounties/route.ts (2)
216-216: LGTM on trigger selection.Indexing WORKFLOW_ATTRIBUTE_TRIGGER by performanceCondition.attribute looks correct given prior zod-validated input.
120-128: The suggested variable rename is incorrect; the current pattern is already correct.The review conflates two distinct variables:
includeSubmissionsCount: a boolean request flag (parsed from query params at line 35)allBountiesSubmissionsCount: the data array from the groupBy query (lines 48, 80–93)The current conditional
...(allBountiesSubmissionsCount && { ... })at line 124 is the correct defensive pattern. It checks whether the actual data exists, not just whether the flag was set. IfincludeSubmissionsCountis true but the query returns no data,allBountiesSubmissionsCountwill be null, and the conditional would correctly prevent spreading undefined data.The "total" semantics are confirmed correct: the query filters by
status: { in: ["submitted", "approved"] }(lines 81–92), so "total" excludes rejected and draft submissions as expected.Likely an incorrect or invalid review comment.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-list.tsx (1)
22-25: LGTM: key construction is correct and includes submissionsCount.Good use of URLSearchParams and null key gating on workspaceId.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-info.tsx (1)
30-33: LGTM: “awaiting review” count is accurate and copy aligns with partners denominator.readyForReviewSubmissions derives from status "submitted"; totalSubmissions includes submitted+approved. Copy uses “partner(s)”. Matches domain constraint (one submission per partner).
Based on learnings
Also applies to: 87-96
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts (1)
23-24: Review comment is incorrect — the described bug scenario cannot occur.Based on schema verification:
Program.defaultGroupIdis defined asString(required, non-optional)ProgramEnrollment.groupIdis defined asString?(optional)- The OR expression
groupId || program.defaultGroupIdalways produces a defined value becauseprogram.defaultGroupIdis requiredWhen
groupIdis null,partnerGroupIdreceives the always-definedprogram.defaultGroupId. The bug premise—thatpartnerGroupIdbecomes falsy/undefined—cannot happen with the current schema.The suggested refactor adds unnecessary complexity for a non-existent condition. The code is safe as written.
Likely an incorrect or invalid review comment.
apps/web/lib/api/bounties/get-bounty-with-details.ts (1)
51-62: No action needed—BountyGroup already has a unique constraintThe Prisma model already defines
@@unique([bountyId, groupId])on line 61 ofpackages/prisma/schema/bounty.prisma, which enforces uniqueness at the database level. JSON_ARRAYAGG will not produce duplicates. The suggested DISTINCT refactor is unnecessary.Likely an incorrect or invalid review comment.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx (1)
34-42: The review comment contains incorrect assumptions about the codebase.The suggested diff cannot be applied as written:
Hook signature mismatch: The
useBountySubmissionsCounthook atapps/web/lib/swr/use-bounty-submissions-count.tsonly accepts{ enabled?: boolean }parameter. It does not have abountyIdparameter. The hook internally usesuseParams()to extractbountyIdfrom the route.Missing property:
BountyExtendedProps(derived fromBountySchemaExtended) contains only base bounty fields extended withpartnersCount. There is nosubmissionsCountDataproperty to seed from.Component context: While
BountyActionButtonis defined outside the[bountyId]route segment, it is currently only used withinbounty-info.tsx(which is inside[bountyId]/), making the route params available viauseParams().The underlying architectural concern about explicit scoping is valid, but implementing the suggested changes requires modifying the hook definition itself—not just the component call site.
Likely an incorrect or invalid review comment.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx (2)
137-163: Verify wrap-around navigation UX.The navigation wraps to the first submission when reaching the end (and vice versa). While functional, this might be unexpected behavior for users who anticipate navigation to stop at boundaries.
Consider whether this wrap-around is intentional or if boundary-stop navigation would be more intuitive. If wrap-around is desired, consider adding a visual indicator (e.g., "1 of N") to help users understand their position in the list.
84-86: Confirm ascending sort order as default.Default sort is
completedAtascending (oldest first). For review workflows, users typically expect newest submissions first (descending order).Verify this is the intended default sort order for the review flow.
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (3)
89-109: Arrow key navigation implemented correctly.The ArrowRight and ArrowLeft shortcuts for next/previous navigation are intuitive and correctly guarded with existence checks for
onNextandonPrevious.
154-180: Navigation controls are well-implemented.The previous/next chevron buttons are properly disabled when the respective callbacks are not provided, and the layout clearly groups the navigation controls separate from the close button.
404-404: Shortcut labels improve discoverability.Displaying "R" and "A" shortcuts on the action buttons is excellent for user education and discoverability.
Also applies to: 423-423
| const { partnerId, includeSubmissionsCount } = | ||
| getBountiesQuerySchema.parse(searchParams); |
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.
🧩 Analysis chain
Confirm boolean coercion for includeSubmissionsCount.
If getBountiesQuerySchema doesn’t coerce string query params, values like "false" will be truthy here and still trigger the groupBy. Ensure z.coerce.boolean().default(false) (or equivalent) is used for includeSubmissionsCount.
Run to confirm:
🏁 Script executed:
#!/bin/bash
rg -nP --type=ts -C3 'getBountiesQuerySchema' && rg -nP --type=ts -C3 'includeSubmissionsCount.*z\.(coerce\.)?boolean'Length of output: 1515
---
🏁 Script executed:
```shell
#!/bin/bash
# Find booleanQuerySchema definition
rg -nP --type=ts -C5 'booleanQuerySchema\s*=' && echo "---" && rg -nP --type=ts -C5 'export.*booleanQuerySchema'
Length of output: 1277
Fix boolean default value in getBountiesQuerySchema.
The includeSubmissionsCount field defaults to the string "false" instead of boolean false. Since the default bypasses the transform, the string value becomes truthy and incorrectly triggers the groupBy operation when the parameter is omitted.
In apps/web/lib/zod/schemas/bounties.ts line 103, change:
includeSubmissionsCount: booleanQuerySchema.optional().default("false"),to:
includeSubmissionsCount: booleanQuerySchema.optional().default(false),🤖 Prompt for AI Agents
In apps/web/app/(ee)/api/bounties/route.ts around lines 35-36 the parsed value
includeSubmissionsCount is currently defaulting to the string "false" in the
schema which makes it truthy and incorrectly triggers grouping; open
apps/web/lib/zod/schemas/bounties.ts (around line 103) and change the
includeSubmissionsCount schema default from the string "false" to the boolean
false so the transform runs and a missing param yields a proper boolean false.
| // Filter bounties based on active tab | ||
| const filteredBounties = useMemo(() => { | ||
| if (!bounties) return []; | ||
|
|
||
| const now = new Date(); | ||
| return bounties.filter((bounty) => { | ||
| const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= now; | ||
|
|
||
| if (activeTab === "active") { | ||
| return !isExpired; | ||
| } else { | ||
| return isExpired; | ||
| } | ||
| }); | ||
| }, [bounties, activeTab]); | ||
|
|
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.
🧩 Analysis chain
Timezone/ordering parity with server counts
Client filters by local now, while counts come from the hook. Ensure server-side counts use the same cutoff (UTC vs local) to avoid “Active/Expired” mismatches near boundaries.
Run to find where bountiesCount is computed and confirm timezone handling:
🏁 Script executed:
#!/bin/bash
rg -nP --type=ts 'bountiesCount' -C3
rg -nP --type=ts '(endsAt|completedAt).*(lt|lte|gt|gte|<=|>=)'Length of output: 4686
---
🏁 Script executed:
```shell
#!/bin/bash
cat apps/web/lib/swr/use-partner-program-bounties.ts
Length of output: 1069
Fix operator mismatch in bounty expiration logic
The client-side filter uses <= while the count computation uses <. At boundary conditions (when bounty.endsAt === now), this causes the bounty count to not match filtered results. Both should use the same operator—recommend changing the count computation to also use <= for consistency:
apps/web/lib/swr/use-partner-program-bounties.ts (line 33)
const isExpired = bounty.endsAt && new Date(bounty.endsAt) <= new Date();
Note: The original review's timezone concern is incorrect; both use client-side local time, not server vs local UTC.
🤖 Prompt for AI Agents
In apps/web/lib/swr/use-partner-program-bounties.ts around line 33, the bounty
expiration check uses a strict '<' operator while the client-side filter in
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx
(lines 29-44) uses '<='; update the expiration check in
use-partner-program-bounties.ts to use '<=' (i.e., treat endsAt equal to now as
expired) so both count computation and UI filtering use the same operator and
produce consistent results.
| <div className="mb-6 grid grid-cols-2 gap-2"> | ||
| {tabs.map((tab) => { | ||
| const isActive = activeTab === tab.id; | ||
|
|
||
| return ( | ||
| <button | ||
| key={tab.id} | ||
| type="button" | ||
| className={cn( | ||
| "border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100", | ||
| isActive | ||
| ? "border-black ring-1 ring-black" | ||
| : "hover:bg-bg-muted", | ||
| )} | ||
| onClick={() => setActiveTab(tab.id)} | ||
| > | ||
| <span className="text-content-default text-xs font-semibold"> | ||
| {tab.label} | ||
| </span> | ||
| {bounties ? ( | ||
| <span className="text-content-emphasis text-base font-semibold"> | ||
| {bountiesCount[tab.id].toLocaleString()} | ||
| </span> | ||
| ) : ( | ||
| <div className="h-6 w-12 animate-pulse rounded-md bg-neutral-200" /> | ||
| )} | ||
| </button> | ||
| ); | ||
| })} | ||
| </div> |
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.
Avoid runtime crash if counts lag behind bounties
Gate on bountiesCount presence or use optional chaining.
- {bounties ? (
+ {bounties && bountiesCount ? (
<span className="text-content-emphasis text-base font-semibold">
- {bountiesCount[tab.id].toLocaleString()}
+ {bountiesCount?.[tab.id]?.toLocaleString() ?? "0"}
</span>
) : (📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <div className="mb-6 grid grid-cols-2 gap-2"> | |
| {tabs.map((tab) => { | |
| const isActive = activeTab === tab.id; | |
| return ( | |
| <button | |
| key={tab.id} | |
| type="button" | |
| className={cn( | |
| "border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100", | |
| isActive | |
| ? "border-black ring-1 ring-black" | |
| : "hover:bg-bg-muted", | |
| )} | |
| onClick={() => setActiveTab(tab.id)} | |
| > | |
| <span className="text-content-default text-xs font-semibold"> | |
| {tab.label} | |
| </span> | |
| {bounties ? ( | |
| <span className="text-content-emphasis text-base font-semibold"> | |
| {bountiesCount[tab.id].toLocaleString()} | |
| </span> | |
| ) : ( | |
| <div className="h-6 w-12 animate-pulse rounded-md bg-neutral-200" /> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| <div className="mb-6 grid grid-cols-2 gap-2"> | |
| {tabs.map((tab) => { | |
| const isActive = activeTab === tab.id; | |
| return ( | |
| <button | |
| key={tab.id} | |
| type="button" | |
| className={cn( | |
| "border-border-subtle flex flex-col gap-1 rounded-lg border p-4 text-left transition-colors duration-100", | |
| isActive | |
| ? "border-black ring-1 ring-black" | |
| : "hover:bg-bg-muted", | |
| )} | |
| onClick={() => setActiveTab(tab.id)} | |
| > | |
| <span className="text-content-default text-xs font-semibold"> | |
| {tab.label} | |
| </span> | |
| {bounties && bountiesCount ? ( | |
| <span className="text-content-emphasis text-base font-semibold"> | |
| {bountiesCount?.[tab.id]?.toLocaleString() ?? "0"} | |
| </span> | |
| ) : ( | |
| <div className="h-6 w-12 animate-pulse rounded-md bg-neutral-200" /> | |
| )} | |
| </button> | |
| ); | |
| })} | |
| </div> |
🤖 Prompt for AI Agents
In
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx
around lines 47–76, the render accesses bountiesCount[tab.id] directly which can
throw if bountiesCount is undefined or missing keys; guard this access and
provide a safe fallback. Replace the direct access with optional chaining and a
fallback (e.g. bountiesCount?.[tab.id] ?? 0) and then call toLocaleString on
that value, or wrap the branch so it only renders the count when bountiesCount
exists, otherwise show the loading skeleton; ensure no runtime code assumes
bountiesCount is always present.
| disabledTooltip={ | ||
| bounty.submissionsCount > 0 | ||
| totalSubmissions > 0 | ||
| ? "Bounties with submissions cannot be deleted." | ||
| : undefined | ||
| } |
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.
Ensure the Delete item is actually disabled, not just tooltiped
If MenuItem doesn’t auto-disable from disabledTooltip, users can still trigger the modal.
- disabledTooltip={
+ disabled={totalSubmissions > 0}
+ disabledTooltip={
totalSubmissions > 0
? "Bounties with submissions cannot be deleted."
: undefined
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| disabledTooltip={ | |
| bounty.submissionsCount > 0 | |
| totalSubmissions > 0 | |
| ? "Bounties with submissions cannot be deleted." | |
| : undefined | |
| } | |
| disabled={totalSubmissions > 0} | |
| disabledTooltip={ | |
| totalSubmissions > 0 | |
| ? "Bounties with submissions cannot be deleted." | |
| : undefined | |
| } |
🤖 Prompt for AI Agents
In
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx
around lines 101 to 105, the Delete MenuItem only sets a disabledTooltip which
does not prevent user interaction; add an explicit disabled={totalSubmissions >
0} prop to the MenuItem (or underlying button) and also guard the click handler
so it returns early when totalSubmissions > 0 (i.e., don't call
setIsDeleteModalOpen or trigger deletion flow) to ensure the item is truly
non-interactive.
| {bounty.submissionsCountData?.total === totalPartnersForBounty ? ( | ||
| <>All</> | ||
| ) : ( | ||
| <> | ||
| <span className="text-content-default"> | ||
| {nFormatter(bounty.submissionsCount, { full: true })} | ||
| {nFormatter(bounty.submissionsCountData?.total ?? 0, { | ||
| full: true, | ||
| })} | ||
| </span>{" "} |
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.
Avoid showing “All” when there are zero participants
Show “All” only when totalPartnersForBounty > 0.
- {bounty.submissionsCountData?.total === totalPartnersForBounty ? (
+ {totalPartnersForBounty > 0 &&
+ bounty.submissionsCountData?.total === totalPartnersForBounty ? (
<>All</>
) : (Based on learnings.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| {bounty.submissionsCountData?.total === totalPartnersForBounty ? ( | |
| <>All</> | |
| ) : ( | |
| <> | |
| <span className="text-content-default"> | |
| {nFormatter(bounty.submissionsCount, { full: true })} | |
| {nFormatter(bounty.submissionsCountData?.total ?? 0, { | |
| full: true, | |
| })} | |
| </span>{" "} | |
| {totalPartnersForBounty > 0 && | |
| bounty.submissionsCountData?.total === totalPartnersForBounty ? ( | |
| <>All</> | |
| ) : ( | |
| <> | |
| <span className="text-content-default"> | |
| {nFormatter(bounty.submissionsCountData?.total ?? 0, { | |
| full: true, | |
| })} | |
| </span>{" "} |
🤖 Prompt for AI Agents
In
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx
around lines 91 to 99, the UI shows "All" even when there are zero participants;
update the conditional to only render "All" when totalPartnersForBounty is
greater than zero and equals the bounty's total (e.g. check
totalPartnersForBounty > 0 && (bounty.submissionsCountData?.total ?? 0) ===
totalPartnersForBounty) so that zero participants render the numeric formatter
instead.
| export const getBountiesQuerySchema = z.object({ | ||
| partnerId: z.string().optional(), | ||
| includeSubmissionsCount: booleanQuerySchema.optional().default("false"), | ||
| }); | ||
|
|
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.
🧩 Analysis chain
Confirm boolean coercion for includeSubmissionsCount
Default is the string "false". Ensure booleanQuerySchema transforms to a boolean; otherwise downstream checks may misbehave.
- includeSubmissionsCount: booleanQuerySchema.optional().default("false"),
+ includeSubmissionsCount: booleanQuerySchema
+ .optional()
+ .default("false")
+ .transform((v) => v === true || v === "true"),Find usages expecting a boolean:
🏁 Script executed:
#!/bin/bash
rg -nP --type=ts '\bincludeSubmissionsCount\b' -C3Length of output: 2293
Now I need to find the booleanQuerySchema definition to verify if it uses standard z.coerce.boolean() or a custom implementation:
I need to find the booleanQuerySchema definition to confirm the issue:
I need to search the codebase directly for the booleanQuerySchema definition:Based on my verification using the information from:
- The grep output showing the actual usage patterns
- Definitive documentation that z.coerce.boolean() converts any non-empty string to true, with only the empty string "" converted to false
- The issue that 'false' will be considered as true when using Zod's coerce.boolean for query parameters
The review comment is correct. The string "false" being passed as a default will coerce to the boolean true, which inverts the intended logic when used in the conditional at line 80.
However, the suggested fix in the review comment is incomplete. Rather than manually transforming after coercion, a better approach is to use Zod 4's z.stringbool() which properly handles string "true"/"false" values.
Confirm boolean coercion for includeSubmissionsCount
Default string "false" will coerce to true because any non-empty string is converted to true boolean in Zod, causing inverted logic when used in conditionals. Update the schema to properly handle query string boolean values instead of relying on standard coerce.
- includeSubmissionsCount: booleanQuerySchema.optional().default("false"),
+ includeSubmissionsCount: z.stringbool().optional().default(false),Alternatively, if booleanQuerySchema is a custom implementation, verify it explicitly handles the "false" string case with proper transformation logic.
Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In apps/web/lib/zod/schemas/bounties.ts around lines 101-105,
includeSubmissionsCount currently uses
booleanQuerySchema.optional().default("false") which will coerce the string
"false" to boolean true; update the schema to parse query booleans correctly by
using Zod v4's z.stringbool() (or, if not on Zod v4, replace with
z.string().optional().transform(s => s === "true") and set default(false) as a
boolean) so the default is a boolean false and the strings "true"/"false" are
interpreted correctly; alternatively ensure the shared booleanQuerySchema is
changed to accept and correctly transform "false" -> false and "true" -> true
and then set default(false) here.
| export const getBountySubmissionsQuerySchema = z | ||
| .object({ | ||
| sortBy: z.enum(BOUNTY_SUBMISSIONS_SORT_BY_COLUMNS).default("createdAt"), | ||
| sortOrder: z.enum(["asc", "desc"]).default("desc"), | ||
| sortBy: z.enum(["completedAt", "performanceCount"]).default("completedAt"), | ||
| sortOrder: z.enum(["asc", "desc"]).default("asc"), | ||
| status: z.nativeEnum(BountySubmissionStatus).optional(), |
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.
🧩 Analysis chain
Default sort flip: verify UX and DB NULL ordering
Switching to sortBy=completedAt and sortOrder=asc changes list order and how “awaiting review” (NULL completedAt) items appear. Ensure all consumers expect this (e.g., navigation “Next/Prev”) and DB queries explicitly handle NULLS FIRST/LAST as intended.
- Confirm route/query applies
NULLS FIRST/LAST(or COALESCE) consistently across MySQL/Postgres. - Audit call sites relying on previous default order.
Script:
🏁 Script executed:
#!/bin/bash
rg -nP --type=ts 'getBountySubmissionsQuerySchema' -C3
rg -nP --type=ts '(completedAt).*ORDER BY|orderBy' -C4Length of output: 100745
🏁 Script executed:
#!/bin/bash
# Find BountySubmission schema definition and completedAt field
rg -nP 'model BountySubmission|completedAt' --type=ts --type=prisma -C2 | head -100
# Check for other calls to getBountySubmissions
rg -nP 'getBountySubmissions' --type=ts -C2Length of output: 2840
🏁 Script executed:
#!/bin/bash
# View full get-bounty-submissions implementation
cat apps/web/lib/api/bounties/get-bounty-submissions.ts
# Find Prisma schema file
fd -e prisma$ -type f | head -5Length of output: 1414
🏁 Script executed:
#!/bin/bash
# Find Prisma schema
find . -name "*.prisma" -type f | head -5
# Also check common locations
ls -la packages/*/prisma/schema.prisma 2>/dev/null || echo "Not in packages"
ls -la prisma/schema.prisma 2>/dev/null || echo "Not in root"
# Search for BountySubmission model in any file
rg -n 'model BountySubmission' --type ts --type txt -A 15 | head -50Length of output: 231
🏁 Script executed:
#!/bin/bash
# View BountySubmission model in Prisma schema
cat ./packages/prisma/schema/bounty.prisma | grep -A 30 'model BountySubmission'
# Also search for recent changes to the schema file
echo "=== Recent git history (if available) ==="
cd ./packages/prisma/schema && git log -n 5 --oneline bounty.prisma 2>/dev/null || echo "Git history not available"Length of output: 1801
Add explicit NULL ordering to sort completed submissions before awaiting-review items
The change defaults to sorting by completedAt ascending, but the query lacks explicit NULL handling. This causes "awaiting review" submissions (NULL completedAt) to appear first instead of last—reversing the intended UX for completed bounties.
Fix required in apps/web/lib/api/bounties/get-bounty-submissions.ts (lines 34-37):
orderBy: {
[sortBy === "completedAt" ? "completedAt" : "performanceCount"]: {
sort: sortOrder,
nulls: "last" // Ensure NULL completedAt items appear after completed submissions
}
},Additionally, add a database index on completedAt in packages/prisma/schema/bounty.prisma for query performance.
🤖 Prompt for AI Agents
In apps/web/lib/zod/schemas/bounties.ts around lines 170 to 174, the sorting
configuration lacks explicit NULL ordering which causes awaiting-review
submissions (NULL completedAt) to appear before completed ones; update the query
in apps/web/lib/api/bounties/get-bounty-submissions.ts (around lines 34-37) to
set the orderBy value to include nulls: "last" for completedAt (i.e., when
sortBy === "completedAt" set nulls: "last" alongside the sort direction) so
NULLs appear after completed items, and add a DB index on completedAt in
packages/prisma/schema/bounty.prisma to improve query performance.
Summary by CodeRabbit
New Features
Bug Fixes