-
Notifications
You must be signed in to change notification settings - Fork 2.8k
Add draft status for bounty submissions #2848
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds draft/submitted lifecycle for bounty submissions across schema, backend actions, and UI; blocks approve/reject for drafts; updates status badges (removes Changes
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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Suggested reviewers
Poem
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 4
🧹 Nitpick comments (11)
apps/web/ui/messages/messages-panel.tsx (2)
1-1: Ensure this is a Client ComponentThis file uses React hooks and
navigator; add"use client"if not already declared elsewhere.Apply if needed:
+"use client";
251-255: Guardnavigatorfor SSR safetyAvoid
ReferenceErrorduring 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 racesUpdate 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 updateCommission 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
createPartnerCommissiondoesn’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 rejectedTooltip 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 bubblingTop‑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‑stateisDraftis adequate, but consider deriving intent earlierHolding
null | booleanis 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 draftWhen mapping existing files you drop
fileNameandsize, then send"File"and0on 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, note.nativeEvent.submitterUsing
SubmitEvent.submitteris 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 emptiesAvoid 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
📒 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.tsapps/web/lib/actions/partners/reject-bounty-submission.tsapps/web/lib/actions/partners/upload-bounty-submission-file.tsapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsxapps/web/ui/partners/bounties/claim-bounty-modal.tsxapps/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.tsapps/web/lib/actions/partners/reject-bounty-submission.tsapps/web/lib/actions/partners/upload-bounty-submission-file.tsapps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-details-sheet.tsxapps/web/app/(ee)/partners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsxapps/web/ui/partners/bounties/claim-bounty-modal.tsxapps/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 — LGTMShort‑circuiting with
message.readInEmail && "text-violet-500"is fine givencnignores falsy values.apps/web/lib/actions/partners/upload-bounty-submission-file.ts (1)
69-76: Guard change to allow continuing drafts — LGTMOnly blocking non‑draft existing submissions matches the draft workflow.
apps/web/lib/actions/partners/reject-bounty-submission.ts (1)
41-45: Draft guard — LGTMPrevents rejecting in‑progress submissions as intended.
apps/web/lib/actions/partners/approve-bounty-submission.ts (1)
45-49: Draft guard — LGTMBlocks 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 — LGTMBlocking on
draftaligns with server guard.apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)
4-9: Newdraftbadge — LGTMConsistent 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 torenderSubmissionStatus— LGTMCleaner separation of concerns.
apps/web/lib/actions/partners/create-bounty-submission.ts (4)
32-35: Input contract for drafts looks goodAdding
isDraftwith a sane default and description is clear and backwards‑compatible.
42-43: Destructuring includes draft flag — goodPlumbs
isDraftthrough the action cleanly.
126-134: Server‑side requirement checks correctly gated behind non‑draftsGood call to skip image/URL enforcement for drafts while preserving validation for finals.
PS: In a prior learning we preferred letting
submissionRequirementsSchema.parsefail ifbounty.submissionRequirementsis null (data integrity). Here we parsebounty.submissionRequirements || []. Confirm this fallback is still intended for non‑performance bounties.
168-171: Early return for drafts in background work is correctShort‑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 clearNice UX touch differentiating save vs submit outcomes.
288-325: Hiding status badge for drafts is correctPrevents confusion before a submission is actually pending review.
| submission = bounty.submissions[0]; | ||
|
|
||
| if (submission.status !== "draft") { | ||
| throw new Error( | ||
| `You already have a ${submission.status} submission for this bounty.`, | ||
| ); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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 missingsubmissionRequirementswith|| [].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-Lengthin 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 betype="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 supportsvariant: "new"; use a known variant if not.If
@dub/ui’sStatusBadgevariant 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: Useupserton 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 ifsubmissionarrives/changes later.When
submissionis fetched after mount (SWR) or updated, form state stays stale.Apply a guarded sync on
submissionchanges:+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.submittercan 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
isDraftexplicitly 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 ifreviewedAtis null.Fallback to
createdAtto 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
📒 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.tsapps/web/ui/partners/bounties/claim-bounty-modal.tsxapps/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.tsapps/web/ui/partners/bounties/claim-bounty-modal.tsxapps/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
BountySubmissionStatusnow 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.
...rtners.dub.co/(dashboard)/programs/[programSlug]/(enrolled)/bounties/partner-bounty-card.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 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
isDraftshadows the stateisDraft; 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.
isDraftstate 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
♻️ Duplicate comments (7)
apps/web/app/app.dub.co/(dashboard)/[slug]/(ee)/program/bounties/[bountyId]/bounty-submission-status-badges.ts (1)
1-6: Sweep for residual 'pending' status usageEnsure 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
onClickhandlers; 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 draftOnly 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 PrismaOnly 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 surprisesAdd
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 statusOn create, you don’t set
statuswhenisDraftis false; Prisma default isdraft, 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 gridsThe previous grid above (Lines 170-172) still has
rounded-b-lg, and this new grid also usesrounded-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 scanningConsider 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 patternsSince 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: ResetisDraftloading state after requestEnsure 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 PUTBrowsers 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 robustlyS3/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 racesTwo near-simultaneous “first submissions” can hit the unique
(bountyId, partnerId)and throw. Wrap the find/update/create in a singleprisma.$transactionand 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
📒 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.tsxapps/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.prismaapps/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+submittedreads 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 — LGTMNew 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 securityAdd
rel="noopener noreferrer"alongsidetarget="_blank".
[raise_nitpick_refactor]- <Link + <Link href={`/programs/${programEnrollment?.program.slug}/earnings?type=custom`} - target="_blank" + target="_blank" + rel="noopener noreferrer"
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
🧹 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
📒 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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
♻️ Duplicate comments (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/urlsdrop user data when not required.- Passing
descriptionwhenundefinedcan 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.okis false, the optimistic tile stays withuploading: 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
fileNameandsize, later sending"File"and0. 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/...ifprogramEnrollmenthasn’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
📒 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.tsapps/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
| 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.", | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| <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), | ||
| ) | ||
| } | ||
| /> | ||
| )} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Summary by CodeRabbit
New Features
Bug Fixes
Style