Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@steven-tey
Copy link
Collaborator

@steven-tey steven-tey commented Oct 20, 2025

Summary by CodeRabbit

  • New Features

    • Header navigation and wrap‑around next/previous controls in submission details, keyboard shortcuts (←/→, A to approve, R to reject), and auto‑advance on approve; buttons show shortcuts.
    • Optionally include per‑bounty submissions counts (total/submitted/approved); UI surfaces “awaiting review” counts.
    • Active/Expired tabs, expired badge on bounty cards, and partner sheet navigation shortcuts.
    • Reject modal now accepts an onReject callback.
  • Bug Fixes

    • Default submission sorting changed to completedAt (ascending) for more relevant ordering.

@vercel
Copy link
Contributor

vercel bot commented Oct 20, 2025

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Oct 20, 2025 11:24pm

💡 Enable Vercel Agent with $100 free credit for automated AI reviews

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 20, 2025

Walkthrough

Adds 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

Cohort / File(s) Summary
Submission details sheet & reject modal
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx, apps/web/ui/partners/reject-bounty-submission-modal.tsx
Adds optional onNext/onPrevious props, renders header chevrons and close control, binds ArrowLeft/ArrowRight/A/R keyboard shortcuts scoped to the sheet, integrates onNext into approve/reject success flows, and exposes an onReject callback and hook param.
Submissions table & navigation
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submissions-table.tsx
Adds navigateToSubmission() with wrap-around next/previous behavior, updates query building (removes submissionId from API query), changes default sort to completedAt, and passes onNext/onPrevious into the details sheet.
API endpoints & data layer
apps/web/app/(ee)/api/bounties/route.ts, apps/web/lib/api/bounties/get-bounty-submissions.ts, apps/web/lib/api/bounties/get-bounty-with-details.ts
Adds optional includeSubmissionsCount to fetch per-bounty submission aggregates (total/submitted/approved), changes status filtering behavior and sort key usage (completedAt vs performanceCount), and removes legacy submissionsCount from getBountyWithDetails.
Schemas & validation
apps/web/lib/zod/schemas/bounties.ts
Adds includeSubmissionsCount to query schema, replaces submissionsCount with optional submissionsCountData (total/submitted/approved), removes BOUNTY_SUBMISSIONS_SORT_BY_COLUMNS, and updates getBountySubmissionsQuerySchema defaults to completedAt/asc.
Bounties list & UI consumption
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-list.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-card.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-info.tsx, apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/bounty-action-button.tsx
Requests includeSubmissionsCount, consumes submissionsCountData for badges/labels/tooltips, computes ready-for-review counts, and updates UI logic and tooltips to use aggregated counts.
Partner-facing pages & hooks
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/page-client.tsx, apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx, apps/web/ui/partners/partner-application-sheet.tsx, apps/web/lib/swr/use-partner-program-bounties.ts, apps/web/ui/layout/sidebar/partners-sidebar-nav.tsx
Adds Active/Expired client-side tabs and expired UI/disabled CTA, computes bountiesCount (active/expired) in the hook, wires ArrowLeft/ArrowRight navigation for partner sheet via onNext/onPrevious, and updates sidebar usage.
Partner-profile route signature
apps/web/app/(ee)/api/partner-profile/programs/[programId]/bounties/route.ts
Adds searchParams parameter to the GET handler signature (no runtime behavior change).
Styling/layout tweak
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/page.tsx
Adds className="pb-10" to PageWidthWrapper for extra bottom padding.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • TWilson023

Poem

🐰
I hop from sheet to sheet with nimble feet,
Left and right I dance to chevron beat,
A to approve, R to nudge away,
Callbacks guide me through each bright display,
Tiny paws, big fixes — I celebrate today!

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.56% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "Improve bounty submissions review flow" is directly related to the primary changes in this pull request. The core modifications center on enhancing how reviewers interact with bounty submissions through new navigation controls (onNext/onPrevious callbacks), keyboard shortcuts (ArrowRight/ArrowLeft for navigation, "A" for approval, "R" for rejection), and improved UI indicators showing submission counts and status. These changes collectively represent a meaningful improvement to the submission review workflow. While the changeset also includes partner-side UI updates and data structure modifications, the title accurately captures the main objective of streamlining and enhancing the submission review process. The title is specific enough ("bounty submissions review flow") that a teammate scanning commit history would understand the primary focus without requiring exhaustive detail.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch improve-bounty-submissions

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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_COLUMNS to 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

📥 Commits

Reviewing files that changed from the base of the PR and between e746780 and 11d07ef.

📒 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.tsx
  • apps/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 submissionId from 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 createdAt to completedAt provides 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 completedAt is 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 the BountySubmissionStatus enum as defined in packages/prisma/schema/bounty.prisma. The type assertion is correct and will not cause runtime issues.

@vercel
Copy link
Contributor

vercel bot commented Oct 20, 2025

Deployment failed with the following error:

Failed to create deployment for team_y0zuoaL8RHti4hQoucTdqRSG in project prj_bLzJgTPWhAyLJ6KKGaYemCx1vZVU: FetchError: request to https://76.76.21.112/v13/now/deployments?ownerId=team_y0zuoaL8RHti4hQoucTdqRSG&projectId=prj_bLzJgTPWhAyLJ6KKGaYemCx1vZVU&skipAutoDetectionConfirmation=1&teamId=team_y0zuoaL8RHti4hQoucTdqRSG&traceCarrier=%7B%22ot-baggage-webhookAt%22%3A%221760993858451%22%2C%22ot-baggage-senderUsername%22%3A%22gh.steven-tey%22%2C%22baggage%22%3A%22webhookAt%3D1760993858451%2CsenderUsername%3Dgh.steven-tey%22%2C%22x-datadog-trace-id%22%3A%228563729327132430625%22%2C%22x-datadog-parent-id%22%3A%224327628612468658308%22%2C%22x-datadog-sampling-priority%22%3A%222%22%2C%22x-datadog-tags%22%3A%22_dd.p.tid%3D68f6a24200000000%2C_dd.p.dm%3D-3%22%2C%22traceparent%22%3A%2200-68f6a2420000000076d87a735052d121-3c0ed34b5602d084-01%22%2C%22tracestate%22%3A%22dd%3Dt.tid%3A68f6a24200000000%3Bt.dm%3A-3%3Bs%3A2%3Bp%3A3c0ed34b5602d084%22%7D failed, reason: socket hang up

Copy link
Contributor

@coderabbitai coderabbitai bot left a 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: Unused searchParams parameter.

The searchParams parameter is destructured but never used in the handler body. Based on the PR context mentioning includeSubmissionsCount being introduced in bounties APIs, this parameter may have been added to support that feature.

Please either:

  1. Remove the unused parameter if it's not needed, or
  2. Implement the expected functionality (e.g., includeSubmissionsCount query parameter support)
 export const GET = withPartnerProfile(
-  async ({ partner, params, searchParams }) => {
+  async ({ partner, params }) => {

Alternatively, if includeSubmissionsCount should 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.
Passing onNext into useRejectBountySubmissionModal as onReject is appropriate; invoking onNext on approve success is consistent. Be aware that if onNext does 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; add aria-label (and optionally title) 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 be type="button"; shortcut labels look good.
Using type="submit" outside a form can cause accidental submits if nesting changes. Switch to button.

-                  <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

📥 Commits

Reviewing files that changed from the base of the PR and between 11d07ef and 44c4f4b.

📒 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 expiredBounty guard.


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 useKeyboardShortcut implementation in packages/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 and useKeyboardShortcut.


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.
If useKeyboardShortcut does 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 useKeyboardShortcut already ignores key events from form fields? If not, I can propose a guarded handler that early-returns when e.target is editable.

Also applies to: 100-110


464-472: Hook API changes read well.
Extending useBountySubmissionDetailsSheet to accept onNext/onPrevious and omitting them from the child props is clean and avoids prop collisions.

@vercel
Copy link
Contributor

vercel bot commented Oct 20, 2025

Deployment failed with the following error:

Failed to create deployment for team_y0zuoaL8RHti4hQoucTdqRSG in project prj_bLzJgTPWhAyLJ6KKGaYemCx1vZVU: FetchError: request to https://76.76.21.112/v13/now/deployments?ownerId=team_y0zuoaL8RHti4hQoucTdqRSG&projectId=prj_bLzJgTPWhAyLJ6KKGaYemCx1vZVU&skipAutoDetectionConfirmation=1&teamId=team_y0zuoaL8RHti4hQoucTdqRSG&traceCarrier=%7B%22ot-baggage-webhookAt%22%3A%221760994413762%22%2C%22ot-baggage-senderUsername%22%3A%22gh.steven-tey%22%2C%22baggage%22%3A%22webhookAt%3D1760994413762%2CsenderUsername%3Dgh.steven-tey%22%2C%22x-datadog-trace-id%22%3A%22301747992656863568%22%2C%22x-datadog-parent-id%22%3A%22250121256213811495%22%2C%22x-datadog-sampling-priority%22%3A%222%22%2C%22x-datadog-tags%22%3A%22_dd.p.tid%3D68f6a46d00000000%2C_dd.p.dm%3D-3%22%2C%22traceparent%22%3A%2200-68f6a46d000000000430063359ffe150-03789bf513661127-01%22%2C%22tracestate%22%3A%22dd%3Dt.tid%3A68f6a46d00000000%3Bt.dm%3A-3%3Bs%3A2%3Bp%3A03789bf513661127%22%7D failed, reason: socket hang up

@steven-tey
Copy link
Collaborator Author

@coderabbitai full review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 20, 2025

✅ Actions performed

Full review triggered.

@steven-tey steven-tey merged commit 84110c6 into main Oct 20, 2025
7 checks passed
@steven-tey steven-tey deleted the improve-bounty-submissions branch October 20, 2025 23:28
Copy link
Contributor

@coderabbitai coderabbitai bot left a 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 default

If 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 || undefined
apps/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 button

Add 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 groups

Explicitly type the parsed groups to avoid any and 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 state

Expose 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” badge

Both 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 default

Removes 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 on showColumns.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

📥 Commits

Reviewing files that changed from the base of the PR and between 12f7e3a and b4e9613.

📒 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.tsx
  • apps/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: useKeyboardShortcut correctly handles changing callbacks.

The hook implementation in packages/ui/src/hooks/use-keyboard-shortcut.tsx includes the callback parameter in the useCallback dependency array (line 93). This ensures that when onNext or onPrevious props 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. If includeSubmissionsCount is true but the query returns no data, allBountiesSubmissionsCount will 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.defaultGroupId is defined as String (required, non-optional)
  • ProgramEnrollment.groupId is defined as String? (optional)
  • The OR expression groupId || program.defaultGroupId always produces a defined value because program.defaultGroupId is required

When groupId is null, partnerGroupId receives the always-defined program.defaultGroupId. The bug premise—that partnerGroupId becomes 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 constraint

The Prisma model already defines @@unique([bountyId, groupId]) on line 61 of packages/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:

  1. Hook signature mismatch: The useBountySubmissionsCount hook at apps/web/lib/swr/use-bounty-submissions-count.ts only accepts { enabled?: boolean } parameter. It does not have a bountyId parameter. The hook internally uses useParams() to extract bountyId from the route.

  2. Missing property: BountyExtendedProps (derived from BountySchemaExtended) contains only base bounty fields extended with partnersCount. There is no submissionsCountData property to seed from.

  3. Component context: While BountyActionButton is defined outside the [bountyId] route segment, it is currently only used within bounty-info.tsx (which is inside [bountyId]/), making the route params available via useParams().

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 completedAt ascending (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 onNext and onPrevious.


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

Comment on lines +35 to +36
const { partnerId, includeSubmissionsCount } =
getBountiesQuerySchema.parse(searchParams);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.

Comment on lines +29 to 44
// 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]);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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.

Comment on lines +47 to +76
<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>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
<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.

Comment on lines 101 to 105
disabledTooltip={
bounty.submissionsCount > 0
totalSubmissions > 0
? "Bounties with submissions cannot be deleted."
: undefined
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +91 to 99
{bounty.submissionsCountData?.total === totalPartnersForBounty ? (
<>All</>
) : (
<>
<span className="text-content-default">
{nFormatter(bounty.submissionsCount, { full: true })}
{nFormatter(bounty.submissionsCountData?.total ?? 0, {
full: true,
})}
</span>{" "}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
{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.

Comment on lines 101 to 105
export const getBountiesQuerySchema = z.object({
partnerId: z.string().optional(),
includeSubmissionsCount: booleanQuerySchema.optional().default("false"),
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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' -C3

Length 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:

  1. The grep output showing the actual usage patterns
  2. Definitive documentation that z.coerce.boolean() converts any non-empty string to true, with only the empty string "" converted to false
  3. 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.

Comment on lines 170 to 174
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(),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 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' -C4

Length 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 -C2

Length 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 -5

Length 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 -50

Length 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants