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

Skip to content

Conversation

@devkiran
Copy link
Collaborator

@devkiran devkiran commented Sep 16, 2025

Summary by CodeRabbit

  • New Features

    • Draft submissions: save progress, resume via “Continue submission,” seed forms from drafts, and explicit Save vs Submit flows.
    • New "Submitted" status/badge; "In progress" (draft) replaces prior pending label.
  • Bug Fixes

    • Block approving/rejecting drafts; action buttons disabled with explanatory tooltips.
    • Allow uploads/edits when an existing draft exists; finalized submissions still blocked.
    • Drafts skip owner notification emails.
  • Style

    • Minor UI text tweaks (tenantId) and billing usage grid now always renders; sidebar bounties count updated.

@vercel
Copy link
Contributor

vercel bot commented Sep 16, 2025

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

Project Deployment Preview Updated (UTC)
dub Ready Ready Preview Sep 16, 2025 11:27pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 16, 2025

Walkthrough

Adds draft/submitted lifecycle for bounty submissions across schema, backend actions, and UI; blocks approve/reject for drafts; updates status badges (removes pending, adds draft and submitted); refactors partner bounty card status rendering and adds draft-aware claim/save flows.

Changes

Cohort / File(s) Summary
Partner card rendering refactor
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx
Extracts submission/status rendering into a private renderSubmissionStatus({ bounty, submission, setShowClaimBountyModal }) helper; handles no submission, draft, submitted, rejected, and approved/completed displays; status label/variant adjustments (e.g., in-progress → submitted with variant new).
Claim modal & partner UX
apps/web/ui/partners/bounties/claim-bounty-modal.tsx
Adds isDraft state and seeds form from an existing draft; implements Save progress (draft) vs Submit (final) flow with confirmation for final submits; enforces validation for non-draft only; calls createSubmission with isDraft and updates UI flows/messages.
Create / upload actions
apps/web/lib/actions/partners/create-bounty-submission.ts
apps/web/lib/actions/partners/upload-bounty-submission-file.ts
create-bounty-submission input gains isDraft; updates existing draft or creates new submission (status draft/submitted); skips notification emails for drafts and uses NewBountySubmission template for final submissions. upload-bounty-submission-file now allows continuing when the existing submission is a draft (only blocks if not draft).
Approve / Reject guards
apps/web/lib/actions/partners/approve-bounty-submission.ts
apps/web/lib/actions/partners/reject-bounty-submission.ts
Add precondition that throws if bountySubmission.status === "draft", preventing approve/reject operations on in-progress submissions.
Reviewer sheet gating
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
Disables Approve/Reject when submission.status === "draft" and shows draft-specific tooltips; preserves existing loading/modal behavior.
Status badges & schema
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts
packages/prisma/schema/bounty.prisma
Removes pending; adds draft (label "In progress", variant pending) and submitted (label "Submitted", variant new); Prisma enum updated (remove pending, add draft and submitted) and default status changed to draft.
Sidebar / count rename
apps/web/ui/layout/sidebar/app-sidebar-nav.tsx
Renames pendingBountySubmissionsCountsubmittedBountiesCount and computes the count by status === "submitted", updating badge logic and mapping.
Email template rename
packages/email/src/templates/bounty-new-submission.tsx
Renames default export BountyPendingReviewNewBountySubmission and updates copy (preview/heading/body) to reflect "New bounty submission".
UI text / small cleanup
apps/web/ui/partners/partner-advanced-settings-modal.tsx
packages/ui/src/icons/payout-platforms/stripe.tsx
Minor label text change (tenantIDtenantId) and formatting cleanup (removed trailing blank line).

Sequence Diagram(s)

sequenceDiagram
  autonumber
  actor Partner
  participant UI as ClaimBountyModal
  participant Action as create-bounty-submission
  participant DB as Database
  participant Mail as Email Worker

  Partner->>UI: Open claim form
  UI->>UI: Load existing draft (if present)

  alt Save progress (isDraft = true)
    Partner->>UI: Click "Save progress"
    UI->>Action: submit(..., isDraft: true)
    Action->>DB: find existing submission (draft or none)
    alt Draft exists
      Action->>DB: update submission (status: draft)
    else Create new draft
      Action->>DB: create submission (status: draft)
    end
    Note over Action,Mail: Skip email notifications for draft
    Action-->>UI: return submission (draft)
    UI-->>Partner: Toast "Progress saved"
  else Submit final (isDraft = false)
    Partner->>UI: Click "Submit" (confirm)
    UI->>Action: submit(..., isDraft: false)
    Action->>DB: find existing submission (draft or none)
    alt Draft exists
      Action->>DB: update submission (status: submitted)
    else Create new submission
      Action->>DB: create submission (status: submitted)
    end
    Action->>Mail: queue owner/partner notifications (NewBountySubmission)
    Action-->>UI: return submission (submitted)
    UI-->>Partner: Toast "Bounty submitted"
  end
Loading
sequenceDiagram
  autonumber
  actor Reviewer
  participant Sheet as SubmissionDetailsSheet
  participant Rules as UI gating

  Reviewer->>Sheet: Open submission
  Sheet->>Rules: Inspect submission.status
  alt status == draft
    Rules->>Sheet: Disable Approve/Reject
    Sheet-->>Reviewer: Show tooltip "Submission in progress"
  else status == submitted
    Rules->>Sheet: Enable Approve/Reject per loading/validity
    Reviewer->>Sheet: Approve or Reject (existing flow)
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • TWilson023

Poem

A small hare saves a draft today,
Carrots stowed while I hop away.
Draft then send, the badges gleam,
Progress saved, then final stream.
Hoppity — code and carrots beam! 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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 "Add draft status for bounty submissions" is concise and accurately captures the primary change in the changeset — introducing a draft submission status across the schema, backend actions, and UI flows (e.g., Prisma enum updates, draft-aware create/update and UI gating). It is specific enough for a teammate scanning history to understand the main intent without listing implementation details.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch bounty-submission-draft

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

🧹 Nitpick comments (11)
apps/web/ui/messages/messages-panel.tsx (2)

1-1: Ensure this is a Client Component

This file uses React hooks and navigator; add "use client" if not already declared elsewhere.

Apply if needed:

+"use client";

251-255: Guard navigator for SSR safety

Avoid ReferenceError during server render.

Apply this diff:

-                      {navigator.platform.startsWith("Mac") ? "⌘" : "^"}
+                      {(typeof navigator !== "undefined" &&
+                        navigator.platform.startsWith("Mac"))
+                        ? "⌘"
+                        : "Ctrl"}
apps/web/lib/actions/partners/reject-bounty-submission.ts (1)

51-77: Make status transition atomic to avoid races

Update can clobber a concurrent approve/reject. Gate by prior status.

Apply pattern:

-      await tx.bountySubmission.update({
-        where: { id: submissionId },
-        data: { status: "rejected", reviewedAt: new Date(), userId: user.id, rejectionReason, rejectionNote, commissionId: null },
-      });
+      const { count } = await tx.bountySubmission.updateMany({
+        where: { id: submissionId, status: { in: ["pending"] } },
+        data: {
+          status: "rejected",
+          reviewedAt: new Date(),
+          userId: user.id,
+          rejectionReason,
+          rejectionNote,
+          commissionId: null,
+        },
+      });
+      if (count === 0) throw new Error("Submission status changed; retry.");
apps/web/lib/actions/partners/approve-bounty-submission.ts (1)

68-95: Reduce inconsistency risk: transaction + atomic update

Commission creation and status update are not atomic; consider wrapping with a transaction and gating by prior status to prevent orphans/races.

Example shape:

await prisma.$transaction(async (tx) => {
  const commission = await createPartnerCommission(/* pass tx if supported */);
  if (!commission) throw new Error("Failed to create commission.");

  const { count } = await tx.bountySubmission.updateMany({
    where: { id: submissionId, status: { in: ["pending"] } },
    data: {
      status: "approved",
      reviewedAt: new Date(),
      userId: user.id,
      rejectionNote: null,
      rejectionReason: null,
      commissionId: commission.id,
    },
  });
  if (count === 0) throw new Error("Submission status changed; retry.");
});

If createPartnerCommission doesn’t accept a tx, consider adding an overload.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (1)

329-338: Disable Reject when already rejected

Tooltip says “already rejected” but the button is still enabled; clicking will just error.

Apply this diff:

-                    disabled={
-                      isApprovingBountySubmission ||
-                      submission.status === "draft"
-                    }
+                    disabled={
+                      isApprovingBountySubmission ||
+                      submission.status === "draft" ||
+                      submission.status === "rejected"
+                    }
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (1)

87-154: Avoid double click handlers / event bubbling

Top‑level card is clickable; inner “Claim bounty”/“Continue submission” also have onClick. This can double‑fire analytics and is unnecessary.

Apply either:

  • Remove the inner onClick, relying on the card click; or
  • Stop propagation inside the inner handler.

Example:

-        onClick={() => setShowClaimBountyModal(true)}
+        onClick={(e) => {
+          e.stopPropagation();
+          setShowClaimBountyModal(true);
+        }}
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (5)

66-67: Tri‑state isDraft is adequate, but consider deriving intent earlier

Holding null | boolean is fine. See below for setting it via button onClick to simplify submit logic and loading/disabled states.


71-79: Preserve file metadata (name/size) when editing an existing draft

When mapping existing files you drop fileName and size, then send "File" and 0 on submit, overwriting real metadata. Carry metadata through state and reuse it when re‑submitting.

Apply this diff:

 interface FileInput {
   id: string;
   file?: File;
   url?: string;
   uploading: boolean;
+  fileName?: string;
+  size?: number;
 }
-  if (submission?.files && submission.files.length > 0) {
-    return submission.files.map((file) => ({
+  if (submission?.files && submission.files.length > 0) {
+    return submission.files.map((file) => ({
       id: uuid(),
-      url: file.url,
+      url: file.url,
+      fileName: file.fileName,
+      size: file.size,
       uploading: false,
       file: undefined,
     }));
   }
-  const finalFiles = files
-    .filter(({ url }) => url)
-    .map(({ file, url }) => ({
-      url: url!,
-      fileName: file?.name || "File",
-      size: file?.size || 0,
-    }));
+  const finalFiles = files
+    .filter(({ url }) => url)
+    .map(({ file, url, fileName, size }) => ({
+      url: url!,
+      fileName: file?.name ?? fileName ?? "File",
+      size: file?.size ?? size ?? 0,
+    }));

Also applies to: 159-165


151-159: Set draft/final intent via button onClick, not e.nativeEvent.submitter

Using SubmitEvent.submitter is brittle and complicates button states. Set the intent on click, consume it in onSubmit, and don’t disable the opposite button based on stale intent (which can trap users after a failed attempt).

Apply this diff:

-        // Determine which button was clicked
-        const submitter = (e.nativeEvent as SubmitEvent)
-          .submitter as HTMLButtonElement;
-
-        const isDraft = submitter?.name === "draft";
-
-        setIsDraft(isDraft);
+        const draft = isDraft === true; // set by button onClick below
-          if (!isDraft) {
+          if (!draft) {
-          const result = await createSubmission({
+          const result = await createSubmission({
             programId: programEnrollment.programId,
             bountyId: bounty.id,
             files: finalFiles,
             urls: finalUrls,
             description,
-            ...(isDraft && { isDraft }),
+            ...(draft && { isDraft: true }),
           });
-          if (!result?.data?.success) {
+          if (!result?.data?.success) {
             throw new Error(
-              isDraft
+              draft
                 ? "Failed to save progress."
                 : "Failed to create submission.",
             );
           }
-          toast.success(
-            isDraft ? "Bounty progress saved." : "Bounty submitted.",
-          );
+          toast.success(draft ? "Bounty progress saved." : "Bounty submitted.");
-                        <Button
+                        <Button
                           variant="secondary"
                           text="Save progress"
                           className="rounded-lg"
                           type="submit"
-                          name="draft"
+                          name="draft"
+                          onClick={() => setIsDraft(true)}
                           loading={isDraft === true}
-                          disabled={fileUploading || isDraft === false}
+                          disabled={fileUploading}
                         />
-                        <Button
+                        <Button
                           variant="primary"
                           text="Submit"
                           className="rounded-lg"
                           type="submit"
-                          name="submit"
+                          name="submit"
+                          onClick={() => setIsDraft(false)}
                           loading={isDraft === false}
-                          disabled={fileUploading || isDraft === true}
+                          disabled={fileUploading}
                         />

Also applies to: 565-581


167-168: Trim URLs before filtering empties

Avoid keeping whitespace‑only entries.

Apply this diff:

-        const finalUrls = urls.map(({ url }) => url).filter(Boolean);
+        const finalUrls = urls
+          .map(({ url }) => url.trim())
+          .filter((u) => u.length > 0);

552-594: Action bar gating and labels look right; small UX tweak

“Continue submission” vs “Claim bounty” is intuitive. After adopting the onClick intent approach above, removing isDraft‑based disabled states (as suggested) will also let users switch actions after failures without closing the modal.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e091cd0 and 560bd25.

📒 Files selected for processing (11)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1 hunks)
  • apps/web/lib/actions/partners/approve-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (4 hunks)
  • apps/web/lib/actions/partners/reject-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1 hunks)
  • apps/web/ui/messages/messages-panel.tsx (1 hunks)
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx (6 hunks)
  • packages/prisma/schema/bounty.prisma (1 hunks)
  • packages/ui/src/icons/payout-platforms/stripe.tsx (0 hunks)
💤 Files with no reviewable changes (1)
  • packages/ui/src/icons/payout-platforms/stripe.tsx
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-26T14:32:33.851Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/actions/partners/create-bounty-submission.ts:105-112
Timestamp: 2025-08-26T14:32:33.851Z
Learning: Non-performance bounties are required to have submissionRequirements. In create-bounty-submission.ts, it's appropriate to let the parsing fail if submissionRequirements is null for non-performance bounties, as this indicates a data integrity issue that should be caught.

Applied to files:

  • apps/web/lib/actions/partners/approve-bounty-submission.ts
  • apps/web/lib/actions/partners/reject-bounty-submission.ts
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-09-12T17:31:10.509Z
Learnt from: devkiran
PR: dubinc/dub#2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.509Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).

Applied to files:

  • apps/web/lib/actions/partners/approve-bounty-submission.ts
  • apps/web/lib/actions/partners/reject-bounty-submission.ts
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.

Applied to files:

  • apps/web/lib/actions/partners/create-bounty-submission.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2)
apps/web/lib/types.ts (1)
  • PartnerBountyProps (561-561)
apps/web/ui/partners/bounties/bounty-performance.tsx (1)
  • BountyPerformance (6-53)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Vade Review
🔇 Additional comments (13)
apps/web/ui/messages/messages-panel.tsx (1)

287-293: StatusIndicator color logic — LGTM

Short‑circuiting with message.readInEmail && "text-violet-500" is fine given cn ignores falsy values.

apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1)

69-76: Guard change to allow continuing drafts — LGTM

Only blocking non‑draft existing submissions matches the draft workflow.

apps/web/lib/actions/partners/reject-bounty-submission.ts (1)

41-45: Draft guard — LGTM

Prevents rejecting in‑progress submissions as intended.

apps/web/lib/actions/partners/approve-bounty-submission.ts (1)

45-49: Draft guard — LGTM

Blocks approving in‑progress submissions as expected.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (1)

348-354: Approve button disabled state — LGTM

Blocking on draft aligns with server guard.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)

4-9: New draft badge — LGTM

Consistent label, variant, and icon for in‑progress state.

apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (1)

57-61: Extraction to renderSubmissionStatus — LGTM

Cleaner separation of concerns.

apps/web/lib/actions/partners/create-bounty-submission.ts (4)

32-35: Input contract for drafts looks good

Adding isDraft with a sane default and description is clear and backwards‑compatible.


42-43: Destructuring includes draft flag — good

Plumbs isDraft through the action cleanly.


126-134: Server‑side requirement checks correctly gated behind non‑drafts

Good call to skip image/URL enforcement for drafts while preserving validation for finals.

PS: In a prior learning we preferred letting submissionRequirementsSchema.parse fail if bounty.submissionRequirements is null (data integrity). Here we parse bounty.submissionRequirements || []. Confirm this fallback is still intended for non‑performance bounties.


168-171: Early return for drafts in background work is correct

Short‑circuiting emails on drafts prevents noisy notifications. Nice.

apps/web/ui/partners/bounties/claim-bounty-modal.tsx (2)

228-247: Success messaging toggled by draft state is clear

Nice UX touch differentiating save vs submit outcomes.


288-325: Hiding status badge for drafts is correct

Prevents confusion before a submission is actually pending review.

Comment on lines +80 to 87
submission = bounty.submissions[0];

if (submission.status !== "draft") {
throw new Error(
`You already have a ${submission.status} submission for this bounty.`,
);
}
}
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

Don’t assume submissions[0]; explicitly pick the draft (and/or order the relation)

If a partner has multiple submissions (e.g., a prior rejected one plus a draft), grabbing index 0 is nondeterministic and can wrongly block continuing an existing draft. Prefer selecting the draft explicitly and ordering the relation so error messages reflect the latest non‑draft when applicable.

Apply this diff to select the draft explicitly and ensure deterministic ordering:

-        include: {
+        include: {
           groups: true,
           submissions: {
             where: {
               partnerId: partner.id,
             },
+            orderBy: { createdAt: "desc" },
           },
         },
-    if (bounty.submissions.length > 0) {
-      submission = bounty.submissions[0];
-
-      if (submission.status !== "draft") {
-        throw new Error(
-          `You already have a ${submission.status} submission for this bounty.`,
-        );
-      }
-    }
+    const existingDraft = bounty.submissions.find((s) => s.status === "draft");
+    if (!existingDraft && bounty.submissions.length > 0) {
+      const latest = bounty.submissions[0];
+      throw new Error(
+        `You already have a ${latest.status} submission for this bounty.`,
+      );
+    }
+    submission = existingDraft ?? null;
📝 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
submission = bounty.submissions[0];
if (submission.status !== "draft") {
throw new Error(
`You already have a ${submission.status} submission for this bounty.`,
);
}
}
const existingDraft = bounty.submissions.find((s) => s.status === "draft");
if (!existingDraft && bounty.submissions.length > 0) {
const latest = bounty.submissions[0];
throw new Error(
`You already have a ${latest.status} submission for this bounty.`,
);
}
submission = existingDraft ?? null;
}
🤖 Prompt for AI Agents
In apps/web/lib/actions/partners/create-bounty-submission.ts around lines 80 to
87, the code currently assumes bounty.submissions[0] is the relevant submission
which is nondeterministic; instead explicitly find the draft submission (e.g.,
submissions.find(s => s.status === 'draft')) and, where appropriate, order the
submissions relation (e.g., by createdAt or updatedAt descending) when loading
the bounty so checks and error messages are deterministic and reflect the latest
non-draft; update the logic to use the found draft for draft-related flows and
use the ordered first non-draft for error messaging.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)

118-122: Don’t mask missing submissionRequirements with || [].

Past guidance was to let parsing fail for non-performance bounties to surface data integrity issues. Using || [] silently weakens validation.

-    const submissionRequirements = submissionRequirementsSchema.parse(
-      bounty.submissionRequirements || [],
-    );
+    const submissionRequirements = submissionRequirementsSchema.parse(
+      bounty.submissionRequirements,
+    );
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (3)

1-1: Missing "use client" — this component uses hooks.

Next.js will treat this as a Server Component without the directive; hooks (useState, useAction, etc.) will fail at build/runtime.

Apply at top of file:

+"use client";

98-127: Upload error handling leaves a stuck “uploading” tile; also avoid sending Content-Length.

  • On failure, the optimistic file entry remains with uploading: true.
  • Manually setting Content-Length in browser requests is unnecessary and can cause CORS/403 issues for signed URLs.

Apply:

-const handleUpload = async (file: File) => {
+const handleUpload = async (file: File) => {
   if (!programEnrollment) return;
-
-  setFiles((prev) => [...prev, { id: uuid(), file, uploading: true }]);
+  const tempId = uuid();
+  setFiles((prev) => [...prev, { id: tempId, file, uploading: true }]);
 
   // TODO: Partners upload URL
-  const result = await uploadFile({
-    programId: programEnrollment.programId,
-    bountyId: bounty.id,
-  });
+  try {
+    const result = await uploadFile({
+      programId: programEnrollment.programId,
+      bountyId: bounty.id,
+    });
 
-  if (!result?.data) throw new Error("Failed to get signed upload URL");
+    if (!result?.data) throw new Error("Failed to get signed upload URL");
 
-  const { signedUrl, destinationUrl } = result.data;
+    const { signedUrl, destinationUrl } = result.data;
 
-  const uploadResponse = await fetch(signedUrl, {
-    method: "PUT",
-    body: file,
-    headers: {
-      "Content-Type": file.type,
-      "Content-Length": file.size.toString(),
-    },
-  });
+    const uploadResponse = await fetch(signedUrl, {
+      method: "PUT",
+      body: file,
+      headers: {
+        "Content-Type": file.type || "application/octet-stream",
+      },
+    });
 
-  if (!uploadResponse.ok) {
-    const result = await uploadResponse.json();
-    toast.error(result.error.message || "Failed to upload screenshot.");
-    return;
-  }
+    if (!uploadResponse.ok) {
+      // Best-effort parse; response may not be JSON
+      let message = "Failed to upload screenshot.";
+      try {
+        const err = await uploadResponse.json();
+        message = err?.error?.message || message;
+      } catch {}
+      throw new Error(message);
+    }
 
-  toast.success(`${file.name} uploaded!`);
-  setFiles((prev) =>
-    prev.map((f) =>
-      f.file === file ? { ...f, uploading: false, url: destinationUrl } : f,
-    ),
-  );
+    toast.success(`${file.name} uploaded!`);
+    setFiles((prev) =>
+      prev.map((f) =>
+        f.id === tempId ? { ...f, uploading: false, url: destinationUrl } : f,
+      ),
+    );
+  } catch (e) {
+    setFiles((prev) => prev.filter((f) => f.id !== tempId));
+    toast.error(e instanceof Error ? e.message : "Failed to upload screenshot.");
+  }
 };

Also applies to: 129-133


483-493: Buttons inside a form should explicitly be type="button" to avoid accidental submits.

The remove-URL and add-URL buttons may submit the form if the underlying Button defaults to type="submit".

 <Button
   variant="outline"
   icon={<Trash className="size-4" />}
   className="w-10 shrink-0 bg-red-50 p-0 text-red-700 hover:bg-red-100"
+  type="button"
   onClick={() =>
     setUrls((prev) => prev.filter((s) => s.id !== id))
   }
 />
@@
 <Button
   variant="secondary"
   text="Add URL"
   className="h-8 rounded-lg"
+  type="button"
   onClick={() =>
     setUrls((prev) => [...prev, { id: uuid(), url: "" }])
   }
 />

Also applies to: 497-507

🧹 Nitpick comments (14)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)

4-9: Verify StatusBadge supports variant: "new"; use a known variant if not.

If @dub/ui’s StatusBadge variant union doesn’t include "new", this will type-check or render incorrectly. Consider "info"/"neutral" if available.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (3)

329-338: Disable Reject when already rejected to match tooltip.

Right now the tooltip says “already rejected” but the button isn’t disabled. Add the condition.

-                    disabled={
-                      isApprovingBountySubmission ||
-                      submission.status === "draft"
-                    }
+                    disabled={
+                      isApprovingBountySubmission ||
+                      submission.status === "draft" ||
+                      submission.status === "rejected"
+                    }

250-272: Add React keys to mapped URL list items.

Prevents list reconciliation warnings.

-                      {submission.urls?.map((url) => (
-                        <div className="relative">
+                      {submission.urls?.map((url, idx) => (
+                        <div key={idx} className="relative">

233-234: Improve image alt text for a11y.

Use filename or a meaningful label; current "object-cover" is unhelpful.

-                            <img src={file.url} alt="object-cover" />
+                            <img
+                              src={file.url}
+                              alt={file.fileName || `File ${idx + 1}`}
+                            />
apps/web/lib/actions/partners/create-bounty-submission.ts (2)

136-164: Use upsert on the composite unique to avoid duplicate-creation races.

Prevents TOCTOU between read-then-create under concurrent requests.

-    // If there is an existing submission, update it
-    if (submission) {
-      submission = await prisma.bountySubmission.update({
-        where: {
-          id: submission.id,
-        },
-        data: {
-          description,
-          ...(requireImage && { files }),
-          ...(requireUrl && { urls }),
-          status: isDraft ? "draft" : "pending",
-        },
-      });
-    }
-    // If there is no existing submission, create a new one
-    else {
-      submission = await prisma.bountySubmission.create({
-        data: {
-          id: createId({ prefix: "bnty_sub_" }),
-          programId: bounty.programId,
-          bountyId: bounty.id,
-          partnerId: partner.id,
-          description,
-          ...(requireImage && { files }),
-          ...(requireUrl && { urls }),
-          ...(isDraft && { status: "draft" }),
-        },
-      });
-    }
+    submission = await prisma.bountySubmission.upsert({
+      where: {
+        bountyId_partnerId: {
+          bountyId: bounty.id,
+          partnerId: partner.id,
+        },
+      },
+      update: {
+        description,
+        ...(requireImage && { files }),
+        ...(requireUrl && { urls }),
+        status: isDraft ? "draft" : "pending",
+      },
+      create: {
+        id: createId({ prefix: "bnty_sub_" }),
+        programId: bounty.programId,
+        bountyId: bounty.id,
+        partnerId: partner.id,
+        description,
+        ...(requireImage && { files }),
+        ...(requireUrl && { urls }),
+        ...(isDraft && { status: "draft" }),
+      },
+    });

227-229: Return the submission id for client flows.

Saves a round-trip when the UI needs to reference the submission.

-    return {
-      success: true,
-    };
+    return { success: true, submissionId: submission.id };
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (8)

67-93: Draft prefill is one-shot; state won’t update if submission arrives/changes later.

When submission is fetched after mount (SWR) or updated, form state stays stale.

Apply a guarded sync on submission changes:

+import { Dispatch, SetStateAction, useEffect, useState } from "react";
@@
-// Initialize form state with existing draft submission data
+// Initialize form state with existing draft submission data
 const [description, setDescription] = useState(submission?.description || "");
@@
 const [files, setFiles] = useState<FileInput[]>(() => {
@@
 });
@@
 const [urls, setUrls] = useState<Url[]>(() => {
@@
 });
+
+// Sync when a draft submission becomes available/changes, without clobbering user edits
+useEffect(() => {
+  if (!submission || submission.status !== "draft") return;
+  setDescription((d) => (d ? d : submission.description || ""));
+  setFiles((prev) =>
+    prev.length || !submission.files?.length
+      ? prev
+      : submission.files.map((file) => ({
+          id: uuid(),
+          url: file.url,
+          uploading: false,
+        })),
+  );
+  setUrls((prev) =>
+    (prev.length > 1 || (prev[0]?.url ?? "") !== "" || !submission.urls?.length)
+      ? prev
+      : submission.urls.map((u) => ({ id: uuid(), url: u })),
+  );
+}, [submission?.id]); // draft identity as dep

98-103: Enforce client-side file constraints before uploading.

The UI promises “JPG/PNG, max 5MB” but doesn’t enforce it.

Apply:

-const handleUpload = async (file: File) => {
+const handleUpload = async (file: File) => {
   if (!programEnrollment) return;
+  if (!file.type.startsWith("image/")) {
+    toast.error("Only image files are allowed.");
+    return;
+  }
+  if (file.size > 5 * 1024 * 1024) {
+    toast.error("Max file size is 5MB.");
+    return;
+  }

150-154: e.nativeEvent.submitter can be flaky; confirm cross‑browser or add a fallback.

Safari support has historically lagged. If undefined (e.g., Enter key submission), defaulting to “submit” is fine, but consider a stateful fallback set by buttons’ onClick.


155-164: Trim and de‑dupe URLs before sending.

Prevents empty/whitespace entries and duplicates.

-const finalUrls = urls.map(({ url }) => url).filter(Boolean);
+const finalUrls = Array.from(
+  new Set(
+    urls
+      .map(({ url }) => url.trim())
+      .filter((u) => u.length > 0),
+  ),
+);

180-184: Send normalized description and explicit draft flag.

Trimming avoids accidental whitespace-only descriptions; sending isDraft explicitly makes intent unambiguous.

-            files: finalFiles,
-            urls: finalUrls,
-            description,
-            ...(isDraft && { isDraft }),
+            files: finalFiles,
+            urls: finalUrls,
+            description: description.trim(),
+            isDraft,

186-193: Surface server error when available.

If the action returns an error message/code, expose it in the thrown Error to aid debugging.

- if (!result?.data?.success) {
-   throw new Error(
-     isDraft ? "Failed to save progress." : "Failed to create submission.",
-   );
- }
+ if (!result?.data?.success) {
+   const msg =
+     (result?.data as any)?.message ||
+     (result as any)?.serverError ||
+     (isDraft ? "Failed to save progress." : "Failed to create submission.");
+   throw new Error(msg);
+ }

271-308: Approved badge date can render blank if reviewedAt is null.

Fallback to createdAt to avoid empty content.

-  Confirmed{" "}
-  {submission.reviewedAt &&
-    formatDate(submission.reviewedAt, { month: "short" })}
+  Confirmed{" "}
+  {formatDate(
+    submission.reviewedAt ?? submission.createdAt,
+    { month: "short" },
+  )}

405-407: Image alt text is not descriptive.

Use the filename or a generic “Uploaded image preview” for a11y.

-<img src={file.url} alt="object-cover" />
+<img src={file.url} alt={file.file?.name || "Uploaded image preview"} />
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e091cd0 and 4eee97a.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (4 hunks)
  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1 hunks)
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx (4 hunks)
  • packages/prisma/schema/bounty.prisma (1 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-26T14:32:33.851Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/actions/partners/create-bounty-submission.ts:105-112
Timestamp: 2025-08-26T14:32:33.851Z
Learning: Non-performance bounties are required to have submissionRequirements. In create-bounty-submission.ts, it's appropriate to let the parsing fail if submissionRequirements is null for non-performance bounties, as this indicates a data integrity issue that should be caught.

Applied to files:

  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-09-12T17:31:10.509Z
Learnt from: devkiran
PR: dubinc/dub#2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.509Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).

Applied to files:

  • apps/web/lib/actions/partners/upload-bounty-submission-file.ts
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.

Applied to files:

  • apps/web/lib/actions/partners/create-bounty-submission.ts
🧬 Code graph analysis (2)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2)
apps/web/lib/types.ts (1)
  • PartnerBountyProps (561-561)
apps/web/ui/partners/bounties/bounty-performance.tsx (1)
  • BountyPerformance (6-53)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (4)
packages/prisma/schema/bounty.prisma (1)

7-11: Enum addition looks good; ensure downstream exhaustiveness + migration/client regen.

  • Addendum: confirm all switch/case usages over BountySubmissionStatus now handle "draft". Run the Prisma migration and regenerate the client in CI to catch enum drift early.
apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1)

69-76: Guard update to allow editing drafts — LGTM.

This correctly permits uploads when an existing submission is a draft and blocks otherwise.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (1)

349-354: Draft gating on Approve — LGTM.

apps/web/ui/partners/bounties/claim-bounty-modal.tsx (1)

167-175: LGTM on draft-aware validation.

The non-draft gating for required fields is correct.

Please confirm the server mirrors these checks to avoid trusting client-only validation.

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

♻️ Duplicate comments (2)
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (2)

198-206: Showing “submitted” success screen after saving a draft is still misleading; keep the form open for drafts.

Calling setSuccess(true) for drafts hides the form. Prefer to only set success for final submissions and keep the draft form open for continued editing.

Apply this diff:

-          toast.success(
-            isDraft ? "Bounty progress saved." : "Bounty submitted.",
-          );
-
-          setSuccess(true);
+          toast.success(draftClicked ? "Bounty progress saved." : "Bounty submitted.");
+          if (!draftClicked) {
+            setSuccess(true);
+          } else {
+            setIsFormOpen(true);
+          }

557-563: “Cancel” currently submits the form; set type="button".

Inside a form, buttons default to submit. Add an explicit type to prevent accidental form submission.

Apply this diff:

 <Button
   variant="outline"
   text="Cancel"
   className="w-fit rounded-lg"
+  type="button"
   onClick={() => setIsFormOpen(false)}
 />
🧹 Nitpick comments (7)
apps/web/ui/partners/partner-advanced-settings-modal.tsx (2)

85-89: Use semantic code markup for the field name in the label.

Since this is an internal field identifier, prefer a semantic element for accessibility/readability while keeping the current styling.

Apply this diff:

-                <span className="rounded-md bg-neutral-200 px-1 py-0.5">
-                  tenantId
-                </span>
+                <code className="rounded-md bg-neutral-200 px-1 py-0.5">
+                  tenantId
+                </code>

105-107: Also use semantic code markup in the helper text.

Mirror the label change so the field name is consistently represented as code.

Apply this diff:

-              <span className="rounded-md bg-orange-100 px-1 py-px">
-                tenantId
-              </span>{" "}
+              <code className="rounded-md bg-orange-100 px-1 py-px">
+                tenantId
+              </code>{" "}
apps/web/lib/actions/partners/approve-bounty-submission.ts (1)

45-50: Good: blocking approvals for drafts. Add a state precondition to prevent concurrent double-approvals/duplicate commissions.

The guard aligns with the new “draft” lifecycle. However, the approval path still has a TOCTOU race: two reviewers could pass status checks and both create commissions before the update, leading to duplicate payouts. Gate the state transition atomically by updating only when status is still "pending" and rolling back the commission if you lose the race.

Apply this diff to make the update idempotent without introducing new statuses:

-    const commission = await createPartnerCommission({
+    const commission = await createPartnerCommission({
       event: "custom",
       partnerId: bountySubmission.partnerId,
       programId: bountySubmission.programId,
       amount: finalRewardAmount,
       quantity: 1,
       user,
       description: `Commission for successfully completed "${bounty.name}" bounty.`,
     });
 
     if (!commission) {
       throw new Error("Failed to create commission for the bounty submission.");
     }
 
-    await prisma.bountySubmission.update({
-      where: {
-        id: submissionId,
-      },
-      data: {
-        status: "approved",
-        reviewedAt: new Date(),
-        userId: user.id,
-        rejectionNote: null,
-        rejectionReason: null,
-        commissionId: commission.id,
-      },
-    });
+    // Only approve if still pending; otherwise cancel the created commission to avoid dupes
+    const updated = await prisma.bountySubmission.updateMany({
+      where: { id: submissionId, status: "pending" },
+      data: {
+        status: "approved",
+        reviewedAt: new Date(),
+        userId: user.id,
+        rejectionNote: null,
+        rejectionReason: null,
+        commissionId: commission.id,
+      },
+    });
+    if (updated.count !== 1) {
+      await prisma.commission.update({
+        where: { id: commission.id },
+        data: { status: "canceled", payoutId: null },
+      });
+      throw new Error("Bounty submission was already processed.");
+    }

Also confirm this remains consistent with the preset vs. custom reward precedence (bounty.rewardAmount ?? rewardAmount), which looks correct per prior decision.

apps/web/lib/actions/partners/reject-bounty-submission.ts (1)

41-45: Good: blocking rejections for drafts. Consider adding a state precondition to avoid races with approval.

The guard is consistent with the approval flow. To prevent simultaneous approve/reject actions from different reviewers, add a conditional update that only transitions from allowed states.

If rejecting can apply to both "pending" and "approved" (with commission cancellation), update conditionally inside the existing transaction:

-      await tx.bountySubmission.update({
-        where: {
-          id: submissionId,
-        },
-        data: {
-          status: "rejected",
-          reviewedAt: new Date(),
-          userId: user.id,
-          rejectionReason,
-          rejectionNote,
-          commissionId: null,
-        },
-      });
+      const updated = await tx.bountySubmission.updateMany({
+        where: {
+          id: submissionId,
+          status: { in: ["pending", "approved"] },
+        },
+        data: {
+          status: "rejected",
+          reviewedAt: new Date(),
+          userId: user.id,
+          rejectionReason,
+          rejectionNote,
+          commissionId: null,
+        },
+      });
+      if (updated.count !== 1) {
+        throw new Error("Bounty submission was already processed.");
+      }

If business rules should prevent rejecting “approved,” restrict the allowed statuses to ["pending"] instead. Please confirm intended policy.

apps/web/ui/partners/bounties/claim-bounty-modal.tsx (3)

151-158: Avoid shadowing state, fix TS spread, and clarify draft intent in submit handler.

  • Local const name isDraft shadows the state isDraft; rename to avoid confusion.
  • ...(isDraft && { isDraft }) can trip TS (“Spread types may only be created from object types”). Prefer a ternary.
  • Keep validation, payload, and toasts keyed off the local flag.

Apply this diff:

-        const submitter = (e.nativeEvent as SubmitEvent)
-          .submitter as HTMLButtonElement;
-
-        const isDraft = submitter?.name === "draft";
-        setIsDraft(isDraft);
+        const submitter = (e.nativeEvent as SubmitEvent | any)
+          ?.submitter as HTMLButtonElement | null;
+        const draftClicked = submitter?.name === "draft";
+        setIsDraft(draftClicked);
...
-          if (!isDraft) {
+          if (!draftClicked) {
             if (imageRequired && finalFiles.length === 0) {
               throw new Error("You must upload at least one image.");
             }
             if (urlRequired && finalUrls.length === 0) {
               throw new Error("You must provide at least one URL.");
             }
           }
...
-            description,
-            ...(isDraft && { isDraft }),
+            description,
+            ...(draftClicked ? { isDraft: true } : {}),
           });
...
-          if (!result?.data?.success) {
-            throw new Error(
-              isDraft
-                ? "Failed to save progress."
-                : "Failed to create submission.",
-            );
-          }
+          if (!result?.data?.success) {
+            throw new Error(
+              draftClicked ? "Failed to save progress." : "Failed to create submission.",
+            );
+          }
-          toast.success(
-            isDraft ? "Bounty progress saved." : "Bounty submitted.",
-          );
+          toast.success(draftClicked ? "Bounty progress saved." : "Bounty submitted.");

Also applies to: 170-180, 184-188, 190-201


169-214: Re-enable buttons after submit by resetting draft state.

isDraft state is used for loading/disabled on the two buttons. On error (or after success), it remains latched, leaving one button disabled. Reset it in a finally block.

Apply this diff:

-      onSubmit={async (e) => {
+      onSubmit={async (e) => {
         e.preventDefault();
         if (!programEnrollment) return;
         ...
-        } catch (error) {
+        } catch (error) {
           toast.error(
             error instanceof Error
               ? error.message
               : "Failed to create submission. Please try again.",
           );
-        }
+        } finally {
+          setIsDraft(null);
+        }
       }}

501-509: Non-submit buttons inside the form should also set type="button".

The remove-URL and “Add URL” buttons live inside the form; make their intent explicit to avoid accidental submits in some browsers/components.

Apply this diff:

 <Button
   variant="outline"
   icon={<Trash className="size-4" />}
   className="w-10 shrink-0 bg-red-50 p-0 text-red-700 hover:bg-red-100"
+  type="button"
   onClick={() =>
     setUrls((prev) => prev.filter((s) => s.id !== id))
   }
 />
...
 <Button
   variant="secondary"
   text="Add URL"
   className="h-8 rounded-lg"
+  type="button"
   onClick={() =>
     setUrls((prev) => [...prev, { id: uuid(), url: "" }])
   }
 />

Also applies to: 515-524

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4eee97a421aa795be4f3a5772741a90fde99f124 and 6a0ae6fd8cab9e2bf60c1592b8238dfccc4644ac.

📒 Files selected for processing (6)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx (2 hunks)
  • apps/web/lib/actions/partners/approve-bounty-submission.ts (1 hunks)
  • apps/web/lib/actions/partners/reject-bounty-submission.ts (1 hunks)
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx (6 hunks)
  • apps/web/ui/partners/partner-advanced-settings-modal.tsx (2 hunks)
  • packages/ui/src/icons/payout-platforms/stripe.tsx (0 hunks)
💤 Files with no reviewable changes (1)
  • packages/ui/src/icons/payout-platforms/stripe.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsx
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-26T14:32:33.851Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/actions/partners/create-bounty-submission.ts:105-112
Timestamp: 2025-08-26T14:32:33.851Z
Learning: Non-performance bounties are required to have submissionRequirements. In create-bounty-submission.ts, it's appropriate to let the parsing fail if submissionRequirements is null for non-performance bounties, as this indicates a data integrity issue that should be caught.

Applied to files:

  • apps/web/lib/actions/partners/approve-bounty-submission.ts
  • apps/web/lib/actions/partners/reject-bounty-submission.ts
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
📚 Learning: 2025-09-12T17:31:10.509Z
Learnt from: devkiran
PR: dubinc/dub#2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.509Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).

Applied to files:

  • apps/web/lib/actions/partners/approve-bounty-submission.ts
  • apps/web/lib/actions/partners/reject-bounty-submission.ts
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (2)
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (2)

68-83: LGTM: seeding form from an existing draft.

Draft hydration for description/files is clear and avoids undefined states.


288-325: LGTM: hiding StatusBadge for drafts.

This matches the new status model and avoids showing “submitted” metadata for in‑progress drafts.

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

♻️ Duplicate comments (7)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)

1-6: Sweep for residual 'pending' status usage

Ensure no UI or logic still references the removed "pending" value.

Run:

#!/bin/bash
# Scan for literal 'pending' usages in bounty contexts
rg -nP -C2 -S --glob '!**/dist/**' --glob '!**/.next/**' "(bounty|submission)[^\\n]{0,120}['\"]pending['\"]|['\"]pending['\"][^\\n]{0,120}(bounty|submission)" -g '**/*.{ts,tsx,js,jsx,prisma}'
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (1)

100-106: Don’t nest interactive elements inside a button (a11y + bubbling)

Remove inner onClick handlers; rely on the outer <button> click.

-      <div
-        className="group-hover:ring-border-subtle flex h-7 w-fit items-center rounded-lg bg-black px-2.5 text-sm text-white transition-all group-hover:ring-2"
-        onClick={() => setShowClaimBountyModal(true)}
-      >
+      <div className="group-hover:ring-border-subtle flex h-7 w-fit items-center rounded-lg bg-black px-2.5 text-sm text-white transition-all group-hover:ring-2">
         Claim bounty
       </div>
@@
-        <div
-          className="group-hover:ring-border-subtle flex h-7 w-fit items-center rounded-lg bg-black px-2.5 text-sm text-white transition-all group-hover:ring-2"
-          onClick={() => setShowClaimBountyModal(true)}
-        >
+        <div className="group-hover:ring-border-subtle flex h-7 w-fit items-center rounded-lg bg-black px-2.5 text-sm text-white transition-all group-hover:ring-2">
           Continue submission
         </div>

Also applies to: 112-117

apps/web/ui/partners/bounties/claim-bounty-modal.tsx (1)

207-213: Don’t show “submitted” success UI after saving a draft

Only set success for final submissions; keep the form open for drafts.

-      toast.success(isDraft ? "Bounty progress saved." : "Bounty submitted.");
-
-      setSuccess(true);
+      toast.success(isDraft ? "Bounty progress saved." : "Bounty submitted.");
+      if (!isDraft) {
+        setSuccess(true);
+      } else {
+        setIsFormOpen(true);
+      }
apps/web/lib/actions/partners/create-bounty-submission.ts (4)

136-149: Avoid passing undefined to Prisma

Only include optional fields when defined.

-      submission = await prisma.bountySubmission.update({
+      submission = await prisma.bountySubmission.update({
         where: {
           id: submission.id,
         },
         data: {
-          description,
+          ...(description !== undefined && { description }),
           ...(requireImage && { files }),
           ...(requireUrl && { urls }),
           status: isDraft ? "draft" : "submitted",
         },
       });

76-87: Pick the draft explicitly; avoid nondeterminism

bounty.submissions[0] depends on DB return order. Find the draft and, if none, use the latest non-draft for the error.

-    let submission: BountySubmission | null = null;
+    let submission: BountySubmission | null = null;
@@
-    if (bounty.submissions.length > 0) {
-      submission = bounty.submissions[0];
-
-      if (submission.status !== "draft") {
-        throw new Error(
-          `You already have a ${submission.status} submission for this bounty.`,
-        );
-      }
-    }
+    if (bounty.submissions.length > 0) {
+      const existingDraft = bounty.submissions.find((s) => s.status === "draft");
+      if (!existingDraft) {
+        const latest = [...bounty.submissions].sort(
+          (a, b) => b.createdAt.getTime() - a.createdAt.getTime(),
+        )[0];
+        throw new Error(
+          `You already have a ${latest.status} submission for this bounty.`,
+        );
+      }
+      submission = existingDraft;
+    }

51-63: Order submissions on read to reduce surprises

Add orderBy: { createdAt: "desc" } to the relation include to make behavior deterministic.

       prisma.bounty.findUniqueOrThrow({
         where: {
           id: bountyId,
         },
         include: {
           groups: true,
           submissions: {
             where: {
               partnerId: partner.id,
             },
+            orderBy: { createdAt: "desc" },
           },
         },
       }),

151-164: Bug: final submissions created with draft status

On create, you don’t set status when isDraft is false; Prisma default is draft, so finals remain drafts and no emails are sent. Set status explicitly.

-      submission = await prisma.bountySubmission.create({
-        data: {
-          id: createId({ prefix: "bnty_sub_" }),
-          programId: bounty.programId,
-          bountyId: bounty.id,
-          partnerId: partner.id,
-          description,
-          ...(requireImage && { files }),
-          ...(requireUrl && { urls }),
-          ...(isDraft && { status: "draft" }),
-        },
-      });
+      submission = await prisma.bountySubmission.create({
+        data: {
+          id: createId({ prefix: "bnty_sub_" }),
+          programId: bounty.programId,
+          bountyId: bounty.id,
+          partnerId: partner.id,
+          ...(description !== undefined && { description }),
+          ...(requireImage && { files }),
+          ...(requireUrl && { urls }),
+          status: isDraft ? "draft" : "submitted",
+        },
+      });
🧹 Nitpick comments (7)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx (1)

203-225: Avoid double bottom rounding on stacked grids

The previous grid above (Lines 170-172) still has rounded-b-lg, and this new grid also uses rounded-b-lg, causing a visual double-rounded seam between sections. Make only the last grid have the bottom rounding.

packages/email/src/templates/bounty-new-submission.tsx (1)

56-71: Email preview: include bounty name for better inbox scanning

Consider including the bounty name in the Preview/Heading for higher relevance in inbox previews (e.g., “New bounty submission: {bounty.name}”).

-      <Preview>New bounty submission</Preview>
+      <Preview>New bounty submission: {bounty.name}</Preview>
@@
-              New bounty submission
+              New bounty submission: {bounty.name}
packages/prisma/schema/bounty.prisma (1)

65-65: Defaulting to draft is fine; consider query patterns

Since drafts are now common, consider adding a composite index to speed frequent lookups (e.g., @@index([bountyId, partnerId, status])) if you routinely fetch by these three.

apps/web/ui/partners/bounties/claim-bounty-modal.tsx (3)

179-220: Reset isDraft loading state after request

Ensure buttons aren’t stuck disabled after the action completes.

-    try {
+    try {
       // ...
-    } catch (error) {
+    } catch (error) {
       toast.error(
         error instanceof Error
           ? error.message
           : "Failed to create submission. Please try again.",
       );
-    }
+    } finally {
+      setIsDraft(null);
+    }

113-119: Drop Content-Length header on browser PUT

Browsers forbid setting Content-Length; also triggers extra preflight.

-      headers: {
-        "Content-Type": file.type,
-        "Content-Length": file.size.toString(),
-      },
+      headers: {
+        "Content-Type": file.type,
+      },

122-126: Handle non-JSON upload errors robustly

S3/GCS signed PUT errors are often XML/text; don’t assume JSON.

-    if (!uploadResponse.ok) {
-      const result = await uploadResponse.json();
-      toast.error(result.error.message || "Failed to upload screenshot.");
-      return;
-    }
+    if (!uploadResponse.ok) {
+      const errText = await uploadResponse.text().catch(() => "");
+      toast.error(errText || "Failed to upload screenshot.");
+      return;
+    }
apps/web/lib/actions/partners/create-bounty-submission.ts (1)

136-164: Guard against create-vs-create races

Two near-simultaneous “first submissions” can hit the unique (bountyId, partnerId) and throw. Wrap the find/update/create in a single prisma.$transaction and handle unique constraint gracefully (retry read or return existing).

// Pseudocode sketch (transactional)
await prisma.$transaction(async (tx) => {
  const existing = await tx.bountySubmission.findFirst({
    where: { bountyId, partnerId: partner.id },
    orderBy: { createdAt: "desc" },
  });
  if (existing) {
    if (existing.status !== "draft") {
      throw new Error(`You already have a ${existing.status} submission for this bounty.`);
    }
    await tx.bountySubmission.update({ /* ... */ });
  } else {
    await tx.bountySubmission.create({ /* ... */ });
  }
});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 6a0ae6f and 08e35cc.

📒 Files selected for processing (7)
  • apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1 hunks)
  • apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx (1 hunks)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (5 hunks)
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx (3 hunks)
  • packages/email/src/templates/bounty-new-submission.tsx (3 hunks)
  • packages/prisma/schema/bounty.prisma (2 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-09-12T17:31:10.509Z
Learnt from: devkiran
PR: dubinc/dub#2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.509Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).

Applied to files:

  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.

Applied to files:

  • packages/prisma/schema/bounty.prisma
  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T14:32:33.851Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/actions/partners/create-bounty-submission.ts:105-112
Timestamp: 2025-08-26T14:32:33.851Z
Learning: Non-performance bounties are required to have submissionRequirements. In create-bounty-submission.ts, it's appropriate to let the parsing fail if submissionRequirements is null for non-performance bounties, as this indicates a data integrity issue that should be caught.

Applied to files:

  • apps/web/lib/actions/partners/create-bounty-submission.ts
🧬 Code graph analysis (4)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/settings/billing/plan-usage.tsx (1)
packages/utils/src/constants/misc.ts (1)
  • INFINITY_NUMBER (37-37)
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (4)
apps/web/lib/partners/get-bounty-reward-description.ts (1)
  • getBountyRewardDescription (4-20)
apps/web/lib/zod/schemas/bounties.ts (3)
  • REJECT_BOUNTY_SUBMISSION_REASONS (146-152)
  • MAX_SUBMISSION_FILES (17-17)
  • MAX_SUBMISSION_URLS (19-19)
packages/utils/src/functions/urls.ts (1)
  • getPrettyUrl (130-138)
apps/web/ui/partners/bounties/bounty-performance.tsx (1)
  • BountyPerformance (6-53)
apps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx (2)
apps/web/lib/types.ts (1)
  • PartnerBountyProps (564-564)
apps/web/ui/partners/bounties/bounty-performance.tsx (1)
  • BountyPerformance (6-53)
apps/web/lib/actions/partners/create-bounty-submission.ts (2)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
packages/email/src/templates/bounty-new-submission.tsx (1)
  • NewBountySubmission (17-110)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Vade Review
  • GitHub Check: build
🔇 Additional comments (3)
packages/prisma/schema/bounty.prisma (1)

6-11: Status enum changes — LGTM

draft + submitted reads well and matches the lifecycle introduced in the PR.

apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)

8-20: Badge mapping for draft/submitted — LGTM

New statuses and icons are consistent with the lifecycle.

apps/web/ui/partners/bounties/claim-bounty-modal.tsx (1)

311-317: External link: add rel for security

Add rel="noopener noreferrer" alongside target="_blank".
[raise_nitpick_refactor]

-                          <Link
+                          <Link
                             href={`/programs/${programEnrollment?.program.slug}/earnings?type=custom`}
-                            target="_blank"
+                            target="_blank"
+                            rel="noopener noreferrer"

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

🧹 Nitpick comments (1)
apps/web/ui/layout/sidebar/app-sidebar-nav.tsx (1)

293-297: Badge behavior when count is 0 shows “New” — confirm intent.
Current logic shows “New” when there are 0 submitted items. If that’s for feature discovery, keep it. If not, prefer no badge when zero.

Apply this diff if you want to hide the badge at zero:

-            badge: submittedBountiesCount
+            badge: submittedBountiesCount
               ? submittedBountiesCount > 99
                 ? "99+"
-                : submittedBountiesCount
-              : "New",
+                : submittedBountiesCount
+              : undefined,
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 08e35cc and 2ff27b7.

📒 Files selected for processing (1)
  • apps/web/ui/layout/sidebar/app-sidebar-nav.tsx (5 hunks)
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-05-29T04:45:18.504Z
Learnt from: devkiran
PR: dubinc/dub#2448
File: packages/email/src/templates/partner-program-summary.tsx:0-0
Timestamp: 2025-05-29T04:45:18.504Z
Learning: In the PartnerProgramSummary email template (packages/email/src/templates/partner-program-summary.tsx), the stat titles are hardcoded constants ("Clicks", "Leads", "Sales", "Earnings") that will always match the ICONS object keys after toLowerCase() conversion, so icon lookup failures are not possible.

Applied to files:

  • apps/web/ui/layout/sidebar/app-sidebar-nav.tsx
📚 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/ui/layout/sidebar/app-sidebar-nav.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: api-tests
  • GitHub Check: Vade Review
🔇 Additional comments (4)
apps/web/ui/layout/sidebar/app-sidebar-nav.tsx (4)

66-66: Add submittedBountiesCount to nav data — looks good.
Matches the new lifecycle and aligns with how other counts are modeled.


199-201: Plumbed submittedBountiesCount into Program area — good.
Prop is correctly threaded to where the badge is rendered.


534-534: Passing submittedBountiesCount into SidebarNav — looks correct.
Keeps the data contract consistent with the Program area consumer.


499-500: Avoid hardcoded status string; make it type-safe.

Declare a typed SUBMITTED_STATUS constant and use it in the find to prevent typos/drift.

-  const submittedBountiesCount =
-    submissionsCount?.find(({ status }) => status === "submitted")?.count || 0;
+  const SUBMITTED_STATUS: SubmissionsCountByStatus["status"] = "submitted";
+  const submittedBountiesCount =
+    submissionsCount?.find((s) => s.status === SUBMITTED_STATUS)?.count ?? 0;

Note: sandbox rg failed to detect .tsx file types so I couldn't verify repo-wide references; also the supplied rg script searches for "pending" (mismatch) — update the check to search for "submitted" and run the search locally/CI to confirm no leftover usages.

@steven-tey steven-tey merged commit c800618 into main Sep 16, 2025
7 of 8 checks passed
@steven-tey steven-tey deleted the bounty-submission-draft branch September 16, 2025 23:34
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

♻️ Duplicate comments (3)
apps/web/lib/actions/partners/create-bounty-submission.ts (3)

55-63: Don’t assume submissions[0]; explicitly select the draft and order deterministically.

Current logic is nondeterministic and can block editing the real draft.

       prisma.bounty.findUniqueOrThrow({
@@
-        include: {
+        include: {
           groups: true,
           submissions: {
             where: {
               partnerId: partner.id,
             },
+            orderBy: { createdAt: "desc" },
           },
         },
@@
-    if (bounty.submissions.length > 0) {
-      submission = bounty.submissions[0];
-
-      if (submission.status !== "draft") {
-        throw new Error(
-          `You already have a ${submission.status} submission for this bounty.`,
-        );
-      }
-    }
+    const existingDraft = bounty.submissions.find((s) => s.status === "draft");
+    if (!existingDraft && bounty.submissions.length > 0) {
+      const latest = bounty.submissions[0];
+      throw new Error(
+        `You already have a ${latest.status} submission for this bounty.`,
+      );
+    }
+    submission = existingDraft ?? null;

Also applies to: 78-87


142-161: Always persist provided files/urls (don’t gate on requirement flags).

Users can attach optional proofs even when not required; current logic discards them.

-          ...(requireImage && { files }),
-          ...(requireUrl && { urls }),
+          files,
+          urls,

(Same change applies to the create path.)


136-164: Race condition can create duplicate submissions; upsert within a transaction and avoid data loss.

  • Find‑then‑create is racy under double‑click/multi‑tab.
  • Conditional spreads for files/urls drop user data when not required.
  • Passing description when undefined can crash Prisma; include it conditionally.
-    // If there is an existing submission, update it
-    if (submission) {
-      submission = await prisma.bountySubmission.update({
-        where: {
-          id: submission.id,
-        },
-        data: {
-          description,
-          ...(requireImage && { files }),
-          ...(requireUrl && { urls }),
-          status: isDraft ? "draft" : "submitted",
-        },
-      });
-    }
-    // If there is no existing submission, create a new one
-    else {
-      submission = await prisma.bountySubmission.create({
-        data: {
-          id: createId({ prefix: "bnty_sub_" }),
-          programId: bounty.programId,
-          bountyId: bounty.id,
-          partnerId: partner.id,
-          description,
-          ...(requireImage && { files }),
-          ...(requireUrl && { urls }),
-          status: isDraft ? "draft" : "submitted",
-        },
-      });
-    }
+    // Upsert within a transaction for consistency and to prevent duplicates
+    submission = await prisma.$transaction(async (tx) => {
+      // Block duplicates if a non-draft already exists
+      const existingNonDraft = await tx.bountySubmission.findFirst({
+        where: { bountyId: bounty.id, partnerId: partner.id, NOT: { status: "draft" } },
+        orderBy: { createdAt: "desc" },
+      });
+      if (existingNonDraft) {
+        throw new Error(`You already have a ${existingNonDraft.status} submission for this bounty.`);
+      }
+
+      // Prefer updating the latest draft if present
+      const latestDraft = await tx.bountySubmission.findFirst({
+        where: { bountyId: bounty.id, partnerId: partner.id, status: "draft" },
+        orderBy: { createdAt: "desc" },
+      });
+
+      if (latestDraft) {
+        return tx.bountySubmission.update({
+          where: { id: latestDraft.id },
+          data: {
+            ...(description !== undefined && { description }),
+            files,
+            urls,
+            status: isDraft ? "draft" : "submitted",
+          },
+        });
+      }
+
+      return tx.bountySubmission.create({
+        data: {
+          id: createId({ prefix: "bnty_sub_" }),
+          programId: bounty.programId,
+          bountyId: bounty.id,
+          partnerId: partner.id,
+          ...(description !== undefined && { description }),
+          files,
+          urls,
+          status: isDraft ? "draft" : "submitted",
+        },
+      });
+    });
🧹 Nitpick comments (7)
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (5)

98-127: On upload failure, the “uploading” tile remains forever. Remove or reset it.

When uploadResponse.ok is false, the optimistic tile stays with uploading: true. Remove the failed entry so the user can retry.

     if (!uploadResponse.ok) {
       const result = await uploadResponse.json();
       toast.error(result.error.message || "Failed to upload screenshot.");
-      return;
+      setFiles((prev) => prev.filter((f) => f.file !== file));
+      return;
     }

45-55: Preserve filename and size for existing files to prevent metadata loss.

Drafts seeded from prior submissions drop fileName and size, later sending "File" and 0. Keep original metadata.

 interface FileInput {
   id: string;
   file?: File;
   url?: string;
+  fileName?: string;
+  size?: number;
   uploading: boolean;
 }
-    if (submission?.files && submission.files.length > 0) {
-      return submission.files.map((file) => ({
+    if (submission?.files && submission.files.length > 0) {
+      return submission.files.map((f) => ({
         id: uuid(),
-        url: file.url,
+        url: f.url,
+        fileName: f.fileName,
+        size: f.size,
         uploading: false,
         file: undefined,
       }));
     }
-    const finalFiles = files
+    const finalFiles = files
       .filter(({ url }) => url)
-      .map(({ file, url }) => ({
+      .map(({ file, url, fileName, size }) => ({
         url: url!,
-        fileName: file?.name || "File",
-        size: file?.size || 0,
+        fileName: file?.name ?? fileName ?? "File",
+        size: file?.size ?? size ?? 0,
       }));

Also applies to: 70-80, 169-176


309-321: Guard “View earnings” link when slug is unavailable.

Avoid /programs/undefined/... if programEnrollment hasn’t loaded.

-                        {submission.status === "approved" && (
+                        {submission.status === "approved" &&
+                          programEnrollment?.program.slug && (
                           <Link
                             href={`/programs/${programEnrollment?.program.slug}/earnings?type=custom`}

457-463: Improve image alt text for accessibility.

alt="object-cover" is a CSS artifact. Use a meaningful label.

-  <img src={file.url} alt="object-cover" />
+  <img src={file.url} alt={file.file?.name ?? "Uploaded proof image"} />

231-236: Harden submitter detection.

Some environments may not populate e.nativeEvent.submitter. Add a safe fallback.

-          const submitter = (e.nativeEvent as SubmitEvent)
-            .submitter as HTMLButtonElement;
+          const submitter = (e.nativeEvent as any)?.submitter as
+            | HTMLButtonElement
+            | undefined;
+          if (!submitter) {
+            // Default to confirmation for safety
+            setShowConfirmModal(true);
+            return;
+          }
apps/web/lib/actions/partners/create-bounty-submission.ts (2)

118-121: Let bad submissionRequirements fail fast (don’t default to []).

Defaulting to [] hides data integrity issues for non‑performance bounties.

-    const submissionRequirements = submissionRequirementsSchema.parse(
-      bounty.submissionRequirements || [],
-    );
+    const submissionRequirements = submissionRequirementsSchema.parse(
+      bounty.submissionRequirements,
+    );

166-205: Enforce uniqueness at the DB layer to eliminate remaining duplication risk.

Add a DB constraint (preferred: partial unique index on drafts) to guarantee one draft per (bountyId, partnerId) and/or a single submission per pair overall. Prisma doesn’t support partial uniques—add via a SQL migration.

Example (Postgres):

-- One draft per partner+bounty
CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
  ub_draft_unique ON "BountySubmission" ("bountyId","partnerId")
  WHERE status = 'draft';

-- (Optional) If business rule is "only one submission ever" per partner+bounty:
-- CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS
--   ub_any_unique ON "BountySubmission" ("bountyId","partnerId");
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2ff27b7 and 6faeb1d.

📒 Files selected for processing (2)
  • apps/web/lib/actions/partners/create-bounty-submission.ts (5 hunks)
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx (3 hunks)
🧰 Additional context used
🧠 Learnings (3)
📚 Learning: 2025-08-26T14:20:23.943Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/app/api/workspaces/[idOrSlug]/notification-preferences/route.ts:13-14
Timestamp: 2025-08-26T14:20:23.943Z
Learning: The updateNotificationPreference action in apps/web/lib/actions/update-notification-preference.ts already handles all notification preference types dynamically, including newBountySubmitted, through its schema validation using the notificationTypes enum and Prisma's dynamic field update pattern.

Applied to files:

  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-08-26T14:32:33.851Z
Learnt from: TWilson023
PR: dubinc/dub#2736
File: apps/web/lib/actions/partners/create-bounty-submission.ts:105-112
Timestamp: 2025-08-26T14:32:33.851Z
Learning: Non-performance bounties are required to have submissionRequirements. In create-bounty-submission.ts, it's appropriate to let the parsing fail if submissionRequirements is null for non-performance bounties, as this indicates a data integrity issue that should be caught.

Applied to files:

  • apps/web/lib/actions/partners/create-bounty-submission.ts
📚 Learning: 2025-09-12T17:31:10.509Z
Learnt from: devkiran
PR: dubinc/dub#2833
File: apps/web/lib/actions/partners/approve-bounty-submission.ts:53-61
Timestamp: 2025-09-12T17:31:10.509Z
Learning: In approve-bounty-submission.ts, the logic `bounty.rewardAmount ?? rewardAmount` is intentional. Bounties with preset reward amounts should use those fixed amounts, and the rewardAmount override parameter is only used when bounty.rewardAmount is null/undefined (for custom reward bounties). This follows the design pattern where bounties are either "flat rate" (fixed amount) or "custom" (variable amount set during approval).

Applied to files:

  • apps/web/lib/actions/partners/create-bounty-submission.ts
  • apps/web/ui/partners/bounties/claim-bounty-modal.tsx
🧬 Code graph analysis (2)
apps/web/lib/actions/partners/create-bounty-submission.ts (2)
apps/web/lib/api/create-id.ts (1)
  • createId (62-67)
packages/email/src/templates/bounty-new-submission.tsx (1)
  • NewBountySubmission (17-110)
apps/web/ui/partners/bounties/claim-bounty-modal.tsx (5)
apps/web/lib/actions/partners/create-bounty-submission.ts (1)
  • createBountySubmissionAction (38-230)
apps/web/lib/partners/get-bounty-reward-description.ts (1)
  • getBountyRewardDescription (4-20)
apps/web/lib/zod/schemas/bounties.ts (3)
  • REJECT_BOUNTY_SUBMISSION_REASONS (146-152)
  • MAX_SUBMISSION_FILES (17-17)
  • MAX_SUBMISSION_URLS (19-19)
packages/utils/src/functions/urls.ts (1)
  • getPrettyUrl (130-138)
apps/web/ui/partners/bounties/bounty-performance.tsx (1)
  • BountyPerformance (6-53)
⏰ 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

Comment on lines +165 to +220
if (!programEnrollment) return;

setIsDraft(isDraft);

<AnimatedSizeContainer
height={isFormOpen}
transition={{ duration: 0.15, ease: "easeInOut" }}
const finalFiles = files
.filter(({ url }) => url)
.map(({ file, url }) => ({
url: url!,
fileName: file?.name || "File",
size: file?.size || 0,
}));

const finalUrls = urls.map(({ url }) => url).filter(Boolean);

try {
// Check the submission requirements are met for non-draft submissions
if (!isDraft) {
setShowConfirmModal(false);
if (imageRequired && finalFiles.length === 0) {
throw new Error("You must upload at least one image.");
}

if (urlRequired && finalUrls.length === 0) {
throw new Error("You must provide at least one URL.");
}
}

const result = await createSubmission({
programId: programEnrollment.programId,
bountyId: bounty.id,
files: finalFiles,
urls: finalUrls,
description,
...(isDraft && { isDraft }),
});

if (!result?.data?.success) {
throw new Error(
isDraft ? "Failed to save progress." : "Failed to create submission.",
);
}

toast.success(isDraft ? "Bounty progress saved." : "Bounty submitted.");

setSuccess(true);
await mutatePrefix(
`/api/partner-profile/programs/${programEnrollment.program.slug}/bounties`,
);
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to create submission. Please try again.",
);
}
};
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

Loading state can get stuck after errors; reset it in finally.

isDraft doubles as a loading flag but is never reset on error, leaving buttons disabled after a failed submission.

Apply this diff to reset state and mildly harden URL handling:

-  const handleSubmission = async ({ isDraft }: { isDraft: boolean }) => {
+  const handleSubmission = async ({ isDraft }: { isDraft: boolean }) => {
     if (!programEnrollment) return;

     setIsDraft(isDraft);
@@
-    const finalUrls = urls.map(({ url }) => url).filter(Boolean);
+    const finalUrls = urls.map(({ url }) => url.trim()).filter(Boolean);
@@
-    } catch (error) {
+    } catch (error) {
       toast.error(
         error instanceof Error
           ? error.message
           : "Failed to create submission. Please try again.",
       );
-    }
+    } finally {
+      setIsDraft(null);
+    }
   };

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In apps/web/ui/partners/bounties/claim-bounty-modal.tsx around lines 165-220,
the submission flow uses isDraft as a loading flag but never resets it on error
and URL handling is weak; add a try/catch/finally where the finally resets the
loading flag (call setIsDraft(false)) and any UI flags you toggle for
confirmation, and before submitting normalize the urls by mapping each entry to
url?.trim() and filtering out falsy/empty strings so finalUrls contains only
valid non-empty strings; ensure the createSubmission call still receives the
same payload shape.

Comment on lines +537 to +547
<Button
variant="outline"
icon={<Trash className="size-4" />}
className="w-10 shrink-0 bg-red-50 p-0 text-red-700 hover:bg-red-100"
onClick={() =>
setUrls((prev) =>
prev.filter((s) => s.id !== id),
)
}
/>
)}
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

Buttons inside forms default to submit; add type="button" to avoid accidental submits.

“Remove URL” and “Add URL” can trigger a form submit.

-                                <Button
+                                <Button
                                   variant="outline"
                                   icon={<Trash className="size-4" />}
                                   className="w-10 shrink-0 bg-red-50 p-0 text-red-700 hover:bg-red-100"
+                                  type="button"
                                   onClick={() =>
                                     setUrls((prev) =>
                                       prev.filter((s) => s.id !== id),
                                     )
                                   }
                                 />
-                            <Button
+                            <Button
                               variant="secondary"
                               text="Add URL"
                               className="h-8 rounded-lg"
+                              type="button"
                               onClick={() =>
                                 setUrls((prev) => [
                                   ...prev,
                                   { id: uuid(), url: "" },
                                 ])
                               }
                             />

Also applies to: 551-556

🤖 Prompt for AI Agents
In apps/web/ui/partners/bounties/claim-bounty-modal.tsx around lines 537-547
(and similarly lines 551-556), the Button components rendered inside the form
default to type="submit" which can cause accidental form submissions when
clicking "Remove URL" or "Add URL"; update each Button to explicitly include
type="button" to prevent form submission and keep their onClick handlers
behavior unchanged.

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.

3 participants