Skip to content

Comments

feat: add structured submission form with draft saving and success state#95

Open
od-hunter wants to merge 1 commit intoboundlessfi:mainfrom
od-hunter:feat/submission-flow
Open

feat: add structured submission form with draft saving and success state#95
od-hunter wants to merge 1 commit intoboundlessfi:mainfrom
od-hunter:feat/submission-flow

Conversation

@od-hunter
Copy link

@od-hunter od-hunter commented Feb 21, 2026

Closes #81

Screenshot 2026-02-21 at 11 28 08 AM

Summary by CodeRabbit

  • New Features

    • Redesigned submission workflow with a dedicated dialog-based form interface
    • Added draft auto-save and recovery for submission form entries
    • Expanded submission capabilities with wallet address, GitHub URL, demo URL, and attachments support
  • Improvements

    • Enhanced validation and error handling for submission requests

@vercel
Copy link

vercel bot commented Feb 21, 2026

@od-hunter is attempting to deploy a commit to the Threadflow Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link

coderabbitai bot commented Feb 21, 2026

📝 Walkthrough

Walkthrough

Implements a structured submission dialog for bounties with client-side form validation, draft persistence, and richer submission fields. Updates bounty detail sidebar CTA to open a dialog instead of GitHub URL. Extends API and server-side handler to validate submissions and store richer metadata including wallet address, demo URL, GitHub URL, explanation, and attachments.

Changes

Cohort / File(s) Summary
API and Route Handler
app/api/bounties/[id]/submit/route.ts, lib/api/bounties.ts
Added schema-based input validation for submissions, explicit bounty existence and type checking, richer submission fields, and new submit API method. Replaced simple presence checks with structured parsing and comprehensive error responses (400/404/409/500).
UI Components
components/bounty-detail/bounty-detail-sidebar-cta.tsx, components/bounty-detail/submission-dialog.tsx
Introduced SubmissionDialog component with form validation, draft persistence via localStorage, attachment management, and submission lifecycle handling. Updated SidebarCTA and MobileCTA to open dialog instead of GitHub URL.
Schemas and Types
components/bounty/forms/schemas.ts, types/participation.ts
Added submissionFormSchema and SubmissionFormValue for form validation. Extended Submission interface with new fields (githubUrl, demoUrl, explanation, attachments, walletAddress). Updated string literal quote style for ApplicationStatus and SubmissionStatus. Added exported type aliases for BountyType, BountyStatus, DifficultyLevel, and ClaimingModel.

Sequence Diagram

sequenceDiagram
    actor User
    participant Dialog as SubmissionDialog
    participant Form as react-hook-form
    participant LocalStorage as Draft Storage
    participant API as bountiesApi.submit
    participant Server as /api/bounties/[id]/submit
    participant Store as BountyStore

    User->>Dialog: Opens submission dialog
    Dialog->>LocalStorage: Restore draft if exists
    alt Draft found
        Dialog->>Form: Populate form with draft
        Dialog->>User: Show draft restored notice
    end

    User->>Form: Fill form (explanation, urls, attachments, wallet)
    User->>Dialog: Click Save Draft
    Dialog->>LocalStorage: Persist form data

    User->>Dialog: Click Submit
    Dialog->>Form: Validate with submissionFormSchema
    alt Validation fails
        Form->>User: Show field errors
    else Validation passes
        Dialog->>API: submit(bountyId, formData + contributorId)
        API->>Server: POST /api/bounties/[id]/submit
        Server->>Server: Parse and validate payload
        Server->>Server: Check bounty exists & type supported
        Server->>Server: Check no duplicate submission
        Server->>Store: addSubmission(enriched data)
        Store->>Server: Return created submission
        Server->>API: Return 200 + submission data
        API->>Dialog: success response
        Dialog->>LocalStorage: Clear draft
        Dialog->>Form: Reset form
        Dialog->>User: Show success toast
        Dialog->>Dialog: Auto-close after delay
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested reviewers

  • Benjtalkshow

Poem

🐰 A dialog blooms where forms now dance,
With drafts that linger in the ether,
Submissions rich with wallet, URLs enhanced—
The bounty flow grows wiser, cleaner, better.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add structured submission form with draft saving and success state' directly reflects the main changes: a new structured submission form with draft persistence and success state UI, aligning with PR objectives.
Linked Issues check ✅ Passed All requirements from #81 are met: structured form UI with expected fields (githubUrl, demoUrl, explanation, attachments, walletAddress), form validations via zod schema, draft saving via localStorage, success state, and graceful error handling.
Out of Scope Changes check ✅ Passed All changes are directly aligned with #81 objectives. Type exports and API enhancements support the structured form, dialog routing replaces direct URL opens, and schema definitions enable validation—all within scope.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


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

@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)
components/bounty-detail/submission-dialog.tsx (3)

75-83: Suppressed react-hooks/exhaustive-deps — acceptable here but document the intent.

Excluding draft and form from the dependency array is intentional: the effect should only fire when open changes. The eslint-disable is pragmatic, but a brief comment explaining why these deps are excluded improves maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/submission-dialog.tsx` around lines 75 - 83, Add an
inline comment above the useEffect explaining that the eslint-disable-next-line
is intentional because the effect should only run when the dialog `open` prop
changes (not when `draft` or `form` mutate), and that `form.reset(draft)` and
`setSubmitted(false)` are intentionally tied to `open` transitions; reference
the useEffect, `open`, `draft`, `form`, `form.reset`, and `setSubmitted` symbols
so future maintainers understand why `react-hooks/exhaustive-deps` was
suppressed.

289-294: "Draft restored" notice persists even after user edits the form.

The draft value from useLocalStorage remains truthy for the entire session (it's only nullified on clearDraft() or submit). This means the "Draft restored from previous session" message stays visible even after the user has modified all fields. Consider tracking whether the draft was actually restored with a separate boolean state that's set once on open.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/submission-dialog.tsx` around lines 289 - 294, The
"Draft restored from previous session" message is tied directly to the
persistent draft from useLocalStorage (draft) so it stays visible after the user
edits; change SubmissionDialog to track a one-time restored flag (e.g.,
restoredDraftShown or hasRestoredDraft) that is set to true only when the dialog
opens and a draft exists, use that boolean to render the message instead of
draft, and ensure clearDraft() or onSubmit/close logic clears or ignores the
flag appropriately; update the component's state initialization and the code
paths around draft, clearDraft(), and the dialog open handler to set/reset this
new flag.

70-73: useFieldArray with as never cast — fragile workaround for string arrays.

useFieldArray is designed for arrays of objects (with an id field). Using it with a flat string[] requires the as never cast and may break with future react-hook-form updates. Consider structuring attachments as { url: string }[] in the schema, which gives proper typing and avoids the cast.

💡 Schema and usage change

In schemas.ts:

- attachments: z.array(z.string().url("Must be a valid URL")).optional(),
+ attachments: z.array(z.object({ url: z.string().url("Must be a valid URL") })).optional(),

Then in the dialog, useFieldArray works naturally without casts, and you access field.url in the input.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/submission-dialog.tsx` around lines 70 - 73, The
current use of useFieldArray({ name: "attachments" as never }) with a flat
string[] is a fragile cast; change the attachments schema/type to an array of
objects (e.g., { url: string }[]) and update the form schema accordingly so
useFieldArray can be used without "as never". Update the component code that
references fields, append, and remove to treat each field as an object (access
field.url in inputs and append({ url: value }) / remove(index) as needed) so
typing is correct and the cast can be removed.
lib/api/bounties.ts (1)

4-4: Inverted dependency: lib/api imports from components/.

lib/api/bounties.ts imports SubmissionFormValue from @/components/bounty/forms/schemas. This creates a dependency from the API/data layer into the component/UI layer, which inverts the typical dependency direction. If the schema needs to be shared between the API client, the server route, and the form UI, consider moving submissionFormSchema and SubmissionFormValue to a shared location such as lib/schemas/ or types/.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/api/bounties.ts` at line 4, lib/api/bounties.ts currently imports
SubmissionFormValue from the UI layer (components/bounty/forms/schemas),
inverting dependencies; move the schema and type (submissionFormSchema and
SubmissionFormValue) to a shared module (e.g., lib/schemas or types) and update
imports in lib/api/bounties.ts, the server route(s), and the form component to
import from that shared location so the API/data layer no longer depends on
components.
components/bounty-detail/bounty-detail-sidebar-cta.tsx (2)

30-43: Duplicated CTA label logic between SidebarCTA and MobileCTA.

ctaLabel() (Line 30) and label() (Line 181) are identical. Extract to a shared helper to avoid the duplication.

♻️ Suggested extraction
function getCtaLabel(bounty: Bounty): string {
  if (bounty.status !== "open")
    return bounty.status === "claimed" ? "Already Claimed" : "Bounty Closed";
  switch (bounty.claimingModel) {
    case "single-claim":
      return "Claim Bounty";
    case "application":
      return "Apply Now";
    case "competition":
      return "Submit Entry";
    case "multi-winner":
      return "Submit Work";
  }
}

Then use getCtaLabel(bounty) in both components.

Also applies to: 181-194

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 30 - 43,
The CTA label logic is duplicated between the ctaLabel() function and label() in
the same file; extract this logic into a single helper like getCtaLabel(bounty)
that takes the bounty object and returns the correct string (handling non-open
status -> "Already Claimed" or "Bounty Closed" and mapping claimingModel values
to "Claim Bounty", "Apply Now", "Submit Entry", "Submit Work"), then replace
both ctaLabel() and label() callers with calls to getCtaLabel(bounty) to remove
duplication.

112-117: Both SidebarCTA and MobileCTA render their own SubmissionDialog — risk of duplicate dialogs.

MobileCTA is hidden via lg:hidden CSS (Line 197) while SidebarCTA likely appears in a sidebar visible at lg+. Both are always mounted in the DOM, each with independent dialogOpen state and their own SubmissionDialog instance. While only one CTA button is visually accessible at a time, both dialog instances exist in the React tree.

Consider lifting the dialog state to a shared parent or rendering a single SubmissionDialog instance to avoid potential issues with duplicate draft handling (both reading/writing the same localStorage key).

Also applies to: 207-212

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx` around lines 112 -
117, Both SidebarCTA and MobileCTA mount independent SubmissionDialog instances
with separate dialogOpen state, causing duplicate dialogs and conflicting
localStorage drafts; fix by lifting the dialog state (dialogOpen and
setDialogOpen) up to their shared parent (the bounty detail component) and
render a single SubmissionDialog there (pass bountyId, bountyTitle, open, and
onOpenChange down or omit child dialogs), or alternatively conditionally render
SubmissionDialog only once based on viewport breakpoint in the parent; update
SidebarCTA and MobileCTA to call a prop handler (e.g., onRequestOpen) instead of
owning dialogOpen so only one SubmissionDialog instance manages
drafts/localStorage.
components/bounty/forms/schemas.ts (3)

78-78: Consider adding wallet address format validation.

walletAddress only checks for a non-empty string. Given the placeholder hints at Stellar (G...) or EVM (0x...) addresses, consider adding a regex or refinement to validate the format. At minimum, a reasonable .max() would prevent abuse.

💡 Example refinement
- walletAddress: z.string().min(1, "Wallet address is required"),
+ walletAddress: z
+   .string()
+   .min(1, "Wallet address is required")
+   .max(256, "Wallet address is too long")
+   .refine(
+     (val) => /^(G[A-Z2-7]{55}|0x[a-fA-F0-9]{40})$/.test(val),
+     "Must be a valid Stellar or EVM wallet address",
+   ),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/forms/schemas.ts` at line 78, The walletAddress schema
currently only checks non-empty; update the walletAddress validator to enforce
format and length by adding a reasonable .max() (e.g., 64) and a refinement or
pattern check that accepts common address formats (e.g., EVM hex starting with
"0x" and Stellar public keys starting with "G") — implement this on the
walletAddress z.string() definition (use .regex(...) or .refine(...) with clear
error messages) so invalid formats are rejected.

77-77: Attachments array has no upper bound.

A user could add an unlimited number of attachment URLs. Consider capping the array length to prevent payload bloat and potential abuse.

💡 Suggested fix
- attachments: z.array(z.string().url("Must be a valid URL")).optional(),
+ attachments: z.array(z.string().url("Must be a valid URL")).max(10, "Maximum 10 attachments allowed").optional(),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/forms/schemas.ts` at line 77, The attachments schema
currently allows an unlimited array length; update the attachments field
(attachments: z.array(...).optional()) to enforce an upper bound (e.g., .max(10,
"At most 10 attachments allowed")) so the Zod schema rejects oversized payloads;
modify the attachments declaration in the schema file to apply the .max(...)
constraint to the z.array(...) validator (and adjust the number if you prefer a
different cap).

71-72: Schema accepts empty strings for URL fields — confirm this is handled downstream.

.optional().or(z.literal("")) means parsed.data.githubUrl can be "". The API route and dialog both normalize empty strings to undefined (e.g., githubUrl: data.githubUrl || undefined), so this works end-to-end. Just noting it's a two-step validation pattern — the schema allows "" and callers must remember to coerce.

An alternative is to use .transform() to normalize at the schema level:

💡 Example with transform
- githubUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")),
+ githubUrl: z.string().url("Must be a valid URL").optional().or(z.literal("")).transform(v => v || undefined),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty/forms/schemas.ts` around lines 71 - 72, The schema
currently allows empty strings for githubUrl and demoUrl via `githubUrl` and
`demoUrl` fields which use `.optional().or(z.literal(""))`, forcing downstream
code to coerce `""` to undefined; instead update those fields in the schema (the
`githubUrl` and `demoUrl` definitions) to normalize empty strings to undefined
with a transform (e.g., replace the union/optional pattern with a single
string/optional and add `.transform(value => value === "" ? undefined : value)`)
so parsing returns `undefined` for empty inputs and callers no longer need to
coerce values.
app/api/bounties/[id]/submit/route.ts (2)

4-4: Server route also imports from components/ — same dependency inversion.

As noted in the lib/api/bounties.ts review, importing submissionFormSchema from @/components/bounty/forms/schemas into a server API route is an inverted dependency. Moving the schema to a shared lib/ or types/ location would fix both import sites.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/bounties/`[id]/submit/route.ts at line 4, submissionFormSchema is
imported into a server route (route.ts) from a components path which creates a
dependency inversion; move the submissionFormSchema definition out of
"@/components/bounty/forms/schemas" into a shared location (e.g., lib/schemas or
types/bounty) and update all consumers (notably the server route handler in
app/api/bounties/[id]/submit/route.ts and the client helper in
lib/api/bounties.ts) to import submissionFormSchema from the new shared module;
ensure the exported symbol name remains submissionFormSchema and adjust any
relative import paths and build/type exports accordingly.

39-50: allowedModels guard is currently a no-op.

The allowlist contains all four claiming model values (single-claim, competition, multi-winner, application), which matches the full claimingModelSchema enum. This means the check will never fail. If the intent is future-proofing for new models, add a comment. Otherwise, remove the dead check to reduce noise.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/bounties/`[id]/submit/route.ts around lines 39 - 50, The
allowedModels guard (the allowedModels array and the includes check against
bounty.claimingModel) is effectively a no-op because it lists all values from
the claimingModel enum; remove the dead check to reduce noise by deleting the
allowedModels array and the if-block that returns the 400 response, or if you
intended this as future-proofing, keep the guard but replace it with a clear
explanatory comment above it (e.g., “// Keep this allowlist to explicitly block
new claiming models until handled”) so the intent is explicit.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/api/bounties/`[id]/submit/route.ts:
- Around line 34-50: The route currently validates existence and claiming model
(BountyStore.getBountyById, allowedModels and bounty.claimingModel) but never
checks bounty.status, so submissions can be made to closed/claimed bounties; add
a guard after fetching the bounty that verifies bounty.status === "open" (or an
allowed set of statuses) and return a 400/409 JSON error (e.g., "Bounty is not
open for submissions") if the status is not open, keeping this check before any
further validation or processing.

In `@components/bounty-detail/submission-dialog.tsx`:
- Around line 114-117: The timeout started in the submission dialog (the
setTimeout that calls onOpenChange(false) and setSubmitted(false)) needs cleanup
to avoid firing after unmount or dialog close; store the timeout ID in a ref
(e.g., timeoutRef), set it when calling setTimeout, and clear it with
clearTimeout(timeoutRef.current) in a useEffect cleanup or whenever the dialog
is closed (and reset the ref to null) so the pending callback cannot run on a
stale closure.
- Line 104: Replace the hardcoded contributorId ("current-user") with the
authenticated user's ID by importing and calling authClient.useSession() inside
the submission handler in submission-dialog.tsx, then set contributorId to
session?.user?.id (or equivalent user id property); also handle the no-session
case (disable submit or show error) so submissions aren't sent with a missing
ID. Ensure you update the payload construction where contributorId is set and
remove the placeholder string.

In `@types/participation.ts`:
- Around line 23-26: The Submission interface defines duplicate fields content
and explanation; either remove content and update all references (including the
submit handler that currently sets content = explanation) to use explanation
only, or mark content as deprecated and add a clear comment/JSdoc on the
Submission type while keeping the submit handler mapping for backward
compatibility (e.g., preserve the assignment in the function that creates
Submission but add a TODO/@deprecated on content). Locate the Submission
interface symbol and the submit handler function that assigns content =
explanation (the handler in the API submit route) and apply one of these two
fixes consistently across usages.

---

Nitpick comments:
In `@app/api/bounties/`[id]/submit/route.ts:
- Line 4: submissionFormSchema is imported into a server route (route.ts) from a
components path which creates a dependency inversion; move the
submissionFormSchema definition out of "@/components/bounty/forms/schemas" into
a shared location (e.g., lib/schemas or types/bounty) and update all consumers
(notably the server route handler in app/api/bounties/[id]/submit/route.ts and
the client helper in lib/api/bounties.ts) to import submissionFormSchema from
the new shared module; ensure the exported symbol name remains
submissionFormSchema and adjust any relative import paths and build/type exports
accordingly.
- Around line 39-50: The allowedModels guard (the allowedModels array and the
includes check against bounty.claimingModel) is effectively a no-op because it
lists all values from the claimingModel enum; remove the dead check to reduce
noise by deleting the allowedModels array and the if-block that returns the 400
response, or if you intended this as future-proofing, keep the guard but replace
it with a clear explanatory comment above it (e.g., “// Keep this allowlist to
explicitly block new claiming models until handled”) so the intent is explicit.

In `@components/bounty-detail/bounty-detail-sidebar-cta.tsx`:
- Around line 30-43: The CTA label logic is duplicated between the ctaLabel()
function and label() in the same file; extract this logic into a single helper
like getCtaLabel(bounty) that takes the bounty object and returns the correct
string (handling non-open status -> "Already Claimed" or "Bounty Closed" and
mapping claimingModel values to "Claim Bounty", "Apply Now", "Submit Entry",
"Submit Work"), then replace both ctaLabel() and label() callers with calls to
getCtaLabel(bounty) to remove duplication.
- Around line 112-117: Both SidebarCTA and MobileCTA mount independent
SubmissionDialog instances with separate dialogOpen state, causing duplicate
dialogs and conflicting localStorage drafts; fix by lifting the dialog state
(dialogOpen and setDialogOpen) up to their shared parent (the bounty detail
component) and render a single SubmissionDialog there (pass bountyId,
bountyTitle, open, and onOpenChange down or omit child dialogs), or
alternatively conditionally render SubmissionDialog only once based on viewport
breakpoint in the parent; update SidebarCTA and MobileCTA to call a prop handler
(e.g., onRequestOpen) instead of owning dialogOpen so only one SubmissionDialog
instance manages drafts/localStorage.

In `@components/bounty-detail/submission-dialog.tsx`:
- Around line 75-83: Add an inline comment above the useEffect explaining that
the eslint-disable-next-line is intentional because the effect should only run
when the dialog `open` prop changes (not when `draft` or `form` mutate), and
that `form.reset(draft)` and `setSubmitted(false)` are intentionally tied to
`open` transitions; reference the useEffect, `open`, `draft`, `form`,
`form.reset`, and `setSubmitted` symbols so future maintainers understand why
`react-hooks/exhaustive-deps` was suppressed.
- Around line 289-294: The "Draft restored from previous session" message is
tied directly to the persistent draft from useLocalStorage (draft) so it stays
visible after the user edits; change SubmissionDialog to track a one-time
restored flag (e.g., restoredDraftShown or hasRestoredDraft) that is set to true
only when the dialog opens and a draft exists, use that boolean to render the
message instead of draft, and ensure clearDraft() or onSubmit/close logic clears
or ignores the flag appropriately; update the component's state initialization
and the code paths around draft, clearDraft(), and the dialog open handler to
set/reset this new flag.
- Around line 70-73: The current use of useFieldArray({ name: "attachments" as
never }) with a flat string[] is a fragile cast; change the attachments
schema/type to an array of objects (e.g., { url: string }[]) and update the form
schema accordingly so useFieldArray can be used without "as never". Update the
component code that references fields, append, and remove to treat each field as
an object (access field.url in inputs and append({ url: value }) / remove(index)
as needed) so typing is correct and the cast can be removed.

In `@components/bounty/forms/schemas.ts`:
- Line 78: The walletAddress schema currently only checks non-empty; update the
walletAddress validator to enforce format and length by adding a reasonable
.max() (e.g., 64) and a refinement or pattern check that accepts common address
formats (e.g., EVM hex starting with "0x" and Stellar public keys starting with
"G") — implement this on the walletAddress z.string() definition (use
.regex(...) or .refine(...) with clear error messages) so invalid formats are
rejected.
- Line 77: The attachments schema currently allows an unlimited array length;
update the attachments field (attachments: z.array(...).optional()) to enforce
an upper bound (e.g., .max(10, "At most 10 attachments allowed")) so the Zod
schema rejects oversized payloads; modify the attachments declaration in the
schema file to apply the .max(...) constraint to the z.array(...) validator (and
adjust the number if you prefer a different cap).
- Around line 71-72: The schema currently allows empty strings for githubUrl and
demoUrl via `githubUrl` and `demoUrl` fields which use
`.optional().or(z.literal(""))`, forcing downstream code to coerce `""` to
undefined; instead update those fields in the schema (the `githubUrl` and
`demoUrl` definitions) to normalize empty strings to undefined with a transform
(e.g., replace the union/optional pattern with a single string/optional and add
`.transform(value => value === "" ? undefined : value)`) so parsing returns
`undefined` for empty inputs and callers no longer need to coerce values.

In `@lib/api/bounties.ts`:
- Line 4: lib/api/bounties.ts currently imports SubmissionFormValue from the UI
layer (components/bounty/forms/schemas), inverting dependencies; move the schema
and type (submissionFormSchema and SubmissionFormValue) to a shared module
(e.g., lib/schemas or types) and update imports in lib/api/bounties.ts, the
server route(s), and the form component to import from that shared location so
the API/data layer no longer depends on components.

Comment on lines +34 to +50
const bounty = BountyStore.getBountyById(bountyId);
if (!bounty) {
return NextResponse.json({ error: "Bounty not found" }, { status: 404 });
}

const existingSubmission = BountyStore.getSubmissionsByBounty(bountyId).find(
s => s.contributorId === contributorId
);
const allowedModels = [
"single-claim",
"competition",
"multi-winner",
"application",
];
if (!allowedModels.includes(bounty.claimingModel)) {
return NextResponse.json(
{ error: "Submission not allowed for this bounty type" },
{ status: 400 },
);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Missing bounty status check — submissions can be made to closed/claimed bounties.

The route verifies the bounty exists and checks the claiming model, but never validates bounty.status === "open". The frontend disables the button for non-open bounties, but a direct API call can bypass this, allowing submissions to closed or already-claimed bounties.

🐛 Proposed fix
     const bounty = BountyStore.getBountyById(bountyId);
     if (!bounty) {
       return NextResponse.json({ error: "Bounty not found" }, { status: 404 });
     }
 
+    if (bounty.status !== "open") {
+      return NextResponse.json(
+        { error: "This bounty is no longer accepting submissions" },
+        { status: 400 },
+      );
+    }
+
     const allowedModels = [
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/bounties/`[id]/submit/route.ts around lines 34 - 50, The route
currently validates existence and claiming model (BountyStore.getBountyById,
allowedModels and bounty.claimingModel) but never checks bounty.status, so
submissions can be made to closed/claimed bounties; add a guard after fetching
the bounty that verifies bounty.status === "open" (or an allowed set of
statuses) and return a 400/409 JSON error (e.g., "Bounty is not open for
submissions") if the status is not open, keeping this check before any further
validation or processing.

githubUrl: data.githubUrl || undefined,
demoUrl: data.demoUrl || undefined,
attachments: data.attachments?.filter(Boolean),
contributorId: "current-user",
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

rg -n --type=ts --type=tsx -C2 'useSession|useAuth|useUser|currentUser' -g '!node_modules'

Repository: boundlessfi/bounties

Length of output: 91


🏁 Script executed:

# Also check the specific file to confirm the line
sed -n '100,110p' components/bounty-detail/submission-dialog.tsx

Repository: boundlessfi/bounties

Length of output: 383


🏁 Script executed:

# Search for any auth context setup or session providers
rg -n --type=ts --type=tsx 'SessionProvider|AuthProvider|AuthContext' -g '!node_modules'

Repository: boundlessfi/bounties

Length of output: 91


🏁 Script executed:

rg -i 'useSession|useAuth|useUser|SessionProvider|AuthProvider|AuthContext' -g '!node_modules' -A2

Repository: boundlessfi/bounties

Length of output: 1519


🏁 Script executed:

# Also check what auth-related files exist
fd -e ts -e js -e tsx -e jsx | rg -i 'auth|session|user' | head -20

Repository: boundlessfi/bounties

Length of output: 438


🏁 Script executed:

# Check if there's an auth or context directory
find . -type d -name '*auth*' -o -type d -name '*context*' -o -type d -name '*session*' 2>/dev/null | grep -v node_modules

Repository: boundlessfi/bounties

Length of output: 94


🏁 Script executed:

cat -n lib/auth-client.ts

Repository: boundlessfi/bounties

Length of output: 606


🏁 Script executed:

# Check the submission-dialog component to see its imports
head -30 components/bounty-detail/submission-dialog.tsx

Repository: boundlessfi/bounties

Length of output: 802


🏁 Script executed:

# Check how use-socket-sync uses the session
sed -n '1,30p' hooks/use-socket-sync.ts

Repository: boundlessfi/bounties

Length of output: 986


🏁 Script executed:

# Check full imports in submission-dialog
grep -n "import.*from\|import {" components/bounty-detail/submission-dialog.tsx | head -50

Repository: boundlessfi/bounties

Length of output: 652


🏁 Script executed:

# Search for where authClient is imported in submission-dialog
grep -n "authClient" components/bounty-detail/submission-dialog.tsx

Repository: boundlessfi/bounties

Length of output: 46


🏁 Script executed:

# Check how other components access the user ID from session
rg -A5 'authClient\.useSession\(\)' --no-heading | head -30

Repository: boundlessfi/bounties

Length of output: 488


🏁 Script executed:

# Look for type definitions for better-auth session
find . -type f \( -name "*.ts" -o -name "*.tsx" \) -exec grep -l "better-auth" {} \; | head -10

Repository: boundlessfi/bounties

Length of output: 116


🏁 Script executed:

# Check server-auth to understand session structure
cat -n lib/server-auth.ts

Repository: boundlessfi/bounties

Length of output: 1132


🏁 Script executed:

# Look at how better-auth is configured in the project
grep -r "better-auth" --include="*.ts" --include="*.tsx" -B2 -A2 | head -40

Repository: boundlessfi/bounties

Length of output: 777


🏁 Script executed:

# Check if there are any type definitions for session
rg "session\?" -A3 -B3 | grep -i "user\|id" | head -20

Repository: boundlessfi/bounties

Length of output: 258


🏁 Script executed:

# Check the entire submission-dialog.tsx around the contributorId line to see full context
sed -n '90,120p' components/bounty-detail/submission-dialog.tsx

Repository: boundlessfi/bounties

Length of output: 870


🏁 Script executed:

# Check if there are any other userId/contributorId references in the file
grep -n "userId\|contributorId\|user\|auth" components/bounty-detail/submission-dialog.tsx

Repository: boundlessfi/bounties

Length of output: 106


🏁 Script executed:

# Look at the bountiesApi.submit to understand what it expects
rg -A10 "submit\(" lib/api/bounties.ts

Repository: boundlessfi/bounties

Length of output: 46


Replace hardcoded contributorId: "current-user" with actual authenticated user ID.

The authClient.useSession() hook (already available in lib/auth-client.ts and used elsewhere in the codebase) can be imported and used to access the current user's ID. Currently, all submissions will be attributed to the placeholder string "current-user", breaking submission attribution.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/submission-dialog.tsx` at line 104, Replace the
hardcoded contributorId ("current-user") with the authenticated user's ID by
importing and calling authClient.useSession() inside the submission handler in
submission-dialog.tsx, then set contributorId to session?.user?.id (or
equivalent user id property); also handle the no-session case (disable submit or
show error) so submissions aren't sent with a missing ID. Ensure you update the
payload construction where contributorId is set and remove the placeholder
string.

Comment on lines +114 to +117
setTimeout(() => {
onOpenChange(false);
setSubmitted(false);
}, 2000);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing cleanup for setTimeout — can fire after unmount.

If the dialog is closed or the component unmounts before the 2-second timeout elapses, onOpenChange(false) and setSubmitted(false) will execute on a stale closure. While React 18+ won't warn, this can cause unexpected state changes. Clear the timeout on unmount or when the dialog closes.

💡 Suggested fix using a ref
+ import { useCallback, useEffect, useRef, useState } from "react";
  ...
  export function SubmissionDialog(...) {
+   const autoCloseRef = useRef<ReturnType<typeof setTimeout>>();
    ...
+   useEffect(() => {
+     return () => clearTimeout(autoCloseRef.current);
+   }, []);
    ...
-     setTimeout(() => {
+     autoCloseRef.current = setTimeout(() => {
        onOpenChange(false);
        setSubmitted(false);
      }, 2000);
📝 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
setTimeout(() => {
onOpenChange(false);
setSubmitted(false);
}, 2000);
autoCloseRef.current = setTimeout(() => {
onOpenChange(false);
setSubmitted(false);
}, 2000);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@components/bounty-detail/submission-dialog.tsx` around lines 114 - 117, The
timeout started in the submission dialog (the setTimeout that calls
onOpenChange(false) and setSubmitted(false)) needs cleanup to avoid firing after
unmount or dialog close; store the timeout ID in a ref (e.g., timeoutRef), set
it when calling setTimeout, and clear it with clearTimeout(timeoutRef.current)
in a useEffect cleanup or whenever the dialog is closed (and reset the ref to
null) so the pending callback cannot run on a stale closure.

Comment on lines +23 to +26
content: string;
githubUrl?: string;
demoUrl?: string;
explanation: string;
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

content and explanation appear redundant.

The Submission interface now has both content: string (Line 23) and explanation: string (Line 26). In the API route (app/api/bounties/[id]/submit/route.ts, Line 70), content is simply set to the same value as explanation. If content is a legacy field kept for backward compatibility, consider marking it as deprecated or documenting why both exist. Otherwise, remove content to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@types/participation.ts` around lines 23 - 26, The Submission interface
defines duplicate fields content and explanation; either remove content and
update all references (including the submit handler that currently sets content
= explanation) to use explanation only, or mark content as deprecated and add a
clear comment/JSdoc on the Submission type while keeping the submit handler
mapping for backward compatibility (e.g., preserve the assignment in the
function that creates Submission but add a TODO/@deprecated on content). Locate
the Submission interface symbol and the submit handler function that assigns
content = explanation (the handler in the API submit route) and apply one of
these two fixes consistently across usages.

@0xdevcollins
Copy link
Contributor

Hello @od-hunter, fix the coderabbit review.

@0xdevcollins
Copy link
Contributor

Also, the wallet address input should be disabled. It will be prefilled, since we’re abstracting the user’s wallet.

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.

Implement Structured Submission Flow

2 participants