Skip to content

feat: Implement Model-Specific Claim/Apply/Join Actions#60

Merged
0xdevcollins merged 7 commits intoboundlessfi:mainfrom
Michaelkingsdev:claim-apply-join
Jan 30, 2026
Merged

feat: Implement Model-Specific Claim/Apply/Join Actions#60
0xdevcollins merged 7 commits intoboundlessfi:mainfrom
Michaelkingsdev:claim-apply-join

Conversation

@Michaelkingsdev
Copy link
Contributor

@Michaelkingsdev Michaelkingsdev commented Jan 29, 2026

Closes #51

Feature: Bounty Participation Logic & Dynamic Actions

Description

This PR implements the end-to-end logic for users to participate in bounties based on their specific claiming models. It transforms the static Claim button into a dynamic action center that handles:

  • Single-Claim: Instant claiming/locking of tasks
  • Application: Submission of cover letters and portfolios
  • Competition: Registration for competitive bounties
  • Milestone: Joining multi-stage milestone bounties

Changes

Frontend

  • components/bounty/bounty-sidebar.tsx
    Completely refactored to check bounty.claimingModel and render the appropriate action button (Claim, Apply, Join, etc.) instead of a static placeholder.

  • components/bounty/application-dialog.tsx
    New component. A modal dialog for users to submit applications with a cover letter.

Backend API

  • app/api/bounties/[id]/claim/route.ts
    New endpoint. Handles exclusivity locking for single-claim bounties.

  • app/api/bounties/[id]/competition/join/route.ts
    New endpoint. Registers users for competition bounties.

  • app/api/bounties/[id]/apply/route.ts
    Integrated with frontend.

  • app/api/bounties/[id]/join/route.ts
    Integrated with frontend.

Data Layer

  • lib/store.ts
    Added competitionParticipations collection and helper methods.

  • types/participation.ts
    Added CompetitionParticipation interface.

  • types/bounty.ts
    Added logic fields (claimedBy, claimedAt, claimExpiresAt) for tracking bounty status.

Verification

  • Single Claim: Verified POST /claim updates status to claimed and sets expiry. UI reflects "Already Claimed".
  • Application: Verified "Apply Now" opens dialog, and submitting creates a new application record.
  • Competition: Verified "Join Competition" adds user to the participant list without locking the bounty.
  • Milestone: Verified generic "Join" works for milestone types.
  • Store: Confirmed in-memory persistence for all new participation types.

Checklist

  • All claiming models supported
  • UI prevents duplicate actions (e.g. re-applying)
  • Error handling for "Bounty not found" or "Invalid model"
  • Type-safe implementation

Summary by CodeRabbit

  • New Features

    • New POST endpoints: claim bounties and join bounty competitions.
    • Application dialog for submitting cover letters and portfolio links.
  • Updates

    • Competition participation support (register/track participants).
    • Bounty model extended with applicants, competitors, and members lists.
    • Sidebar actions adapt per bounty type with dynamic labels and in-button loading.
    • Simple server-side auth helper (dev/mock support).
  • Refactor

    • Minor internal cleanup and test import adjustment; streamlined error handling.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 29, 2026

📝 Walkthrough

Walkthrough

Adds model-specific bounty participation flows: new POST API routes for claiming and competition-joining, a client ApplicationDialog and sidebar action refactor, store support for competition participations and partial bounty updates, plus small type, auth helper, and test import tweaks.

Changes

Cohort / File(s) Summary
API: claim & competition join
app/api/bounties/[id]/claim/route.ts, app/api/bounties/[id]/competition/join/route.ts
New POST endpoints: /claim validates single-claim/open rules and updates bounty status/claim fields; /competition/join validates competition model, prevents duplicate joins, and creates a CompetitionParticipation.
UI: application dialog & sidebar
components/bounty/application-dialog.tsx, components/bounty/bounty-sidebar.tsx
Adds ApplicationDialog (cover letter, portfolio) and refactors sidebar to a unified action flow with loading state, generic POST handler, model-specific labels/flows, and a mock CURRENT_USER_ID used in requests.
Data layer: store & types
lib/store.ts, types/participation.ts, types/bounty.ts
Store extended with competitionParticipations, addCompetitionParticipation, getCompetitionParticipationsByBounty, and updateBounty; adds CompetitionParticipation & CompetitionStatus types; Bounty gains optional applicants, competitors, and members.
Auth helper
lib/server-auth.ts
Adds getCurrentUser() and exported User interface (cookie-based/mock session resolution).
Minor edits & tests
app/api/bounties/[id]/milestones/advance/route.ts, app/api/applications/[id]/review/route.ts, app/api/bounties/[id]/apply/route.ts, app/api/bounties/[id]/join/route.ts, app/api/bounties/[id]/submit/route.ts, app/api/submissions/[id]/select/route.ts, lib/store.test.ts
Small catch-clause changes to bare catch in multiple routes and removal of an unused test import; no functional logic changes beyond removing the error binding.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant UI as Bounty Sidebar / ApplicationDialog
    participant API as Next.js API Route
    participant Store as BountyStore

    User->>UI: Trigger action (Claim / Join / Apply)
    activate UI
    UI->>UI: Show loading or open dialog

    alt single-claim
        UI->>API: POST /api/bounties/:id/claim { contributorId }
        activate API
        API->>Store: getBounty(id)
        API->>Store: updateBounty(id, { status:'claimed', claimedBy, claimedAt, claimExpiresAt, updatedAt })
        Store-->>API: updated bounty
        API-->>UI: 200 OK (updated bounty)
        deactivate API
    else competition
        UI->>API: POST /api/bounties/:id/competition/join { contributorId }
        activate API
        API->>Store: getBounty(id)
        API->>Store: addCompetitionParticipation(participation)
        Store-->>API: participation
        API-->>UI: 201 Created (participation)
        deactivate API
    else application
        UI->>UI: Collect { coverLetter, portfolioUrl } and POST /api/bounties/:id/apply
        activate API
        API->>Store: getBounty(id)
        API->>Store: createApplication(...)
        Store-->>API: application
        API-->>UI: 201 Created (application)
        deactivate API
    end

    UI->>UI: Hide loading, refresh view
    deactivate UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped in with a form and a carrot-bright grin,
Clicked claim, joined a race, or applied with a spin,
Dialogs and routes stitched tight like a stitch,
Stores saved my hops and a deadline-known twitch —
Carrots, code, and a bounty to win! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% 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 accurately and clearly summarizes the main change—implementing model-specific claim/apply/join actions across the entire codebase.
Linked Issues check ✅ Passed All four claiming models are fully implemented with corresponding backend endpoints and frontend UI logic supporting the acceptance criteria.
Out of Scope Changes check ✅ Passed Minor changes to catch clauses and test imports are incidental cleanup without functional impact; all core changes directly address issue #51.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

🤖 Fix all issues with AI agents
In `@app/api/bounties/`[id]/claim/route.ts:
- Around line 12-17: The handler currently reads contributorId from
request.json() and trusts it; instead derive the contributor identity from the
server-side auth/session and ignore the request-supplied contributorId. Replace
the usage of contributorId from the body (the variable read after
request.json()) with the authenticated user id obtained from your auth/session
helper (e.g., getServerSession, getUserFromToken, or whatever auth utility your
app uses), and if no authenticated user is present return an appropriate 401/403
via NextResponse.json; if you still accept a body contributorId only use it to
validate that it matches the authenticated id and reject mismatches rather than
using it as the source of truth. Ensure any references in route.ts
(request.json(), the contributorId variable, and the error response) are updated
accordingly.
- Around line 41-43: The code calls BountyStore.updateBounty(bountyId, updates)
and returns success even if it returns null; add a defensive null check after
the call (check updatedBounty) and if null return an error response (e.g.,
NextResponse.json({ success: false, error: 'Bounty not found' }, { status: 404
}) or similar) instead of the current success path; ensure you still return the
updatedBounty when non-null so callers get the updated data.

In `@app/api/bounties/`[id]/competition/join/route.ts:
- Around line 14-18: Do not accept contributorId from request.json; instead
derive the contributor identity from the server-side auth/session (e.g., call
your session helper or auth provider inside the same route handler) and use that
value wherever contributorId is needed; remove the client-supplied contributorId
check, replace it with an authentication check that returns a 401/403 via
NextResponse.json when the session is missing, and continue using the
server-derived contributorId in the existing logic that currently references the
contributorId variable (also update any error messages that previously mentioned
"Missing contributorId" to reflect authentication failure).
- Around line 21-29: The route allows joins regardless of bounty state; add a
guard after the existing BountyStore.getBountyById and bounty.claimingModel
checks to reject joins for non-open bounties (e.g., check bounty.status !==
'open' or a provided isOpen/isClosed flag) and return an appropriate error
response (400/409) via NextResponse.json; update the route handler in route.ts
(around the existing bounty and claimingModel checks) to perform this status
check before proceeding.

In `@components/bounty/bounty-sidebar.tsx`:
- Around line 106-108: The variable body in bounty-sidebar.tsx is never
reassigned; change its declaration from let body = {} to const body = {} so it
satisfies ESLint prefer-const; keep label and endpoint as-is and continue
passing body into handleAction(...) (the function referenced as handleAction at
the call site).
🧹 Nitpick comments (3)
components/bounty/application-dialog.tsx (1)

10-17: Remove or use the unused bountyId prop to clear the lint warning.

Static analysis reports bountyId is unused here; either drop it (and update components/bounty/bounty-sidebar.tsx) or use it to bind form IDs.

🧹 Possible cleanup
 interface ApplicationDialogProps {
-    bountyId: string
     bountyTitle: string
     onApply: (data: { coverLetter: string, portfolioUrl?: string }) => Promise<void>
     trigger: React.ReactNode
 }

-export function ApplicationDialog({ bountyId, bountyTitle, onApply, trigger }: ApplicationDialogProps) {
+export function ApplicationDialog({ bountyTitle, onApply, trigger }: ApplicationDialogProps) {
components/bounty/bounty-sidebar.tsx (2)

3-9: Remove unused cn import to satisfy the lint warning.

🧹 Cleanup
-import { cn } from "@/lib/utils"

49-67: Prefer router.refresh() over full window.location.reload() in the App Router.

This avoids a full page reload and preserves client state.

♻️ Suggested change
-import { useMemo, useState } from "react"
+import { useMemo, useState } from "react"
+import { useRouter } from "next/navigation"
@@
-// import { useRouter } from "next/navigation" // If we need refresh
@@
-  // const router = useRouter()
+  const router = useRouter()
@@
-      alert('Success!')
-      window.location.reload()
+      alert('Success!')
+      router.refresh()

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

Caution

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

⚠️ Outside diff range comments (1)
components/bounty/bounty-sidebar.tsx (1)

49-139: Remove contributorId from the apply flow payload.

The apply endpoint expects applicantId, coverLetter, and portfolioUrl—not contributorId. Line 55 injects contributorId into all payloads, but the apply route (Line 100) should not include it. While the endpoints are permissive and don't reject extra fields, this creates unnecessary payload overhead. Refactor handleAction to accept an optional field exclusion list or pass request-specific payloads to avoid bloating the apply request.

🤖 Fix all issues with AI agents
In `@components/bounty/application-dialog.tsx`:
- Around line 1-81: The dialog currently closes unconditionally after awaiting
onApply in ApplicationDialog.handleSubmit; change ApplicationDialogProps.onApply
to return Promise<boolean>, update handleSubmit to await const success = await
onApply({...}) and only call setOpen(false) when success === true, and keep the
loading state/error handling as-is; also update the caller (the handler in
bounty-sidebar.tsx, e.g., handleAction) to return true on successful submit and
false on failure so failures don’t close the dialog.
🧹 Nitpick comments (1)
types/bounty.ts (1)

55-55: Use a named Milestone type instead of object[].

The milestones field is used in the codebase (e.g., accessing .length in the milestone advancement API), but remains weakly typed as object[]. A named alias makes the intent explicit and allows for safer evolution of the milestone shape.

♻️ Suggested refactor
 export type ClaimingModel = 'single-claim' | 'application' | 'competition' | 'multi-winner' | 'milestone'
 
+export type Milestone = Record<string, unknown>
+
 export interface Bounty {
   ...
-  milestones?: object[] // Optional milestone definition
+  milestones?: Milestone[] // Optional milestone definition
 }

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

🤖 Fix all issues with AI agents
In `@app/api/bounties/`[id]/claim/route.ts:
- Around line 26-54: Current read-then-update flow using
BountyStore.getBountyById and BountyStore.updateBounty is non-atomic and allows
race conditions; refactor by adding an atomic store method (e.g.,
BountyStore.claimBounty(bountyId, userId, expiresAt, now)) that performs the
check-and-set inside the store (verify claimingModel === 'single-claim' and
status === 'open' and then update
status/claimedBy/claimedAt/claimExpiresAt/updatedAt in one operation) and
returns null or an error on failure; replace the get+update sequence in route.ts
with a single call to claimBounty and handle its success/failure responses (404
if missing, 400/409 for invalid state, 500 for store error) accordingly.
- Around line 18-20: The code calls await request.json() which will throw on
empty/invalid bodies; wrap the parse in a safe try/catch (or use await
request.text() + JSON.parse inside try) and set body = {} on error so
destructuring const { contributorId } = body does not crash the route handler;
update the handler in app/api/bounties/[id]/claim/route.ts to use this safe
parse and proceed when contributorId is undefined.

In `@app/api/bounties/`[id]/competition/join/route.ts:
- Around line 34-49: The code currently performs a separate existence check and
insert (in route.ts using BountyStore.getCompetitionParticipationsByBounty(...)
then BountyStore.addCompetitionParticipation(...)), which allows races to create
duplicates; add an atomic helper on BountyStore (e.g.,
addCompetitionParticipationIfAbsent(bountyId, contributorId,
participationFactory)) that checks for an existing participation by
bountyId+contributorId and either inserts the new CompetitionParticipation
(created via the provided factory so IDs/timestamps are only generated on
success) or returns/throws a "already exists" result; then replace the existing
check+add in route.ts with a single call to addCompetitionParticipationIfAbsent
and translate the "already exists" outcome into the 409 NextResponse.json,
otherwise return success.

In `@lib/server-auth.ts`:
- Around line 14-27: Current code in server-auth.ts treats presence of the
session cookie as sufficient to return a mock user; change the logic so mock
auth is ONLY allowed when NODE_ENV === "development" or an explicit env flag
(e.g., ALLOW_MOCK_AUTH === "true"), and when a real session cookie exists call
the real verification routine instead of returning the mock user. Specifically:
update the conditional around cookies() and sessionCookie so the mock user
return (id/name/email) is gated by process.env.NODE_ENV === "development" ||
process.env.ALLOW_MOCK_AUTH === "true"; when sessionCookie is present and not in
mock mode, call your session verification function (e.g.,
verifySessionToken(sessionCookie) or getUserFromSession(sessionCookie)) and
return that user on success, otherwise return null; ensure you reference the
sessionCookie variable, cookies() call, and replace the unconditional mock
return in server-auth.ts.

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

Caution

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

⚠️ Outside diff range comments (1)
components/bounty/bounty-sidebar.tsx (1)

23-56: Replace mock user IDs and stop trusting client-supplied IDs.

Hardcoding CURRENT_USER_ID and sending user IDs from the client is insecure and can allow spoofing if the server trusts these fields. Derive the user from authenticated server context and remove client-sent IDs (or ensure the server ignores/overrides them).

🔧 Suggested direction
-  // Mock user ID for now - in real app this comes from auth context
-  const CURRENT_USER_ID = "mock-user-123"
+  // Use authenticated user on the server; do not trust client-provided IDs

   const handleAction = async (endpoint: string, body: object = {}) => {
     setLoading(true)
     try {
       const res = await fetch(endpoint, {
         method: 'POST',
         headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({ contributorId: CURRENT_USER_ID, ...body })
+        body: JSON.stringify(body)
       })
-          onApply={async (data) => {
-            return await handleAction(`/api/bounties/${bounty.id}/apply`, { ...data, applicantId: CURRENT_USER_ID })
-          }}
+          onApply={async (data) => {
+            return await handleAction(`/api/bounties/${bounty.id}/apply`, data)
+          }}

Also applies to: 101-103

🤖 Fix all issues with AI agents
In `@components/bounty/bounty-sidebar.tsx`:
- Around line 78-129: Update renderActionButton to detect if the CURRENT_USER_ID
has already participated and disable/relabel the action accordingly: for the
application flow check a per-bounty applicants list or flag (e.g.,
bounty.applicants.includes(CURRENT_USER_ID)) and for competition/milestone flows
check the appropriate participation list (e.g., bounty.competitors or
bounty.members/includes(CURRENT_USER_ID)); when true return a disabled Button
(or disable the ApplicationDialog trigger) with labels like "Already Applied" /
"Already Joined" and avoid calling handleAction; reference renderActionButton,
ApplicationDialog, CURRENT_USER_ID, and handleAction when adding these checks
and the new labels.

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

🤖 Fix all issues with AI agents
In `@components/bounty/bounty-sidebar.tsx`:
- Around line 20-25: Replace the hard-coded CURRENT_USER_ID in the BountySidebar
component with a real value coming from props or auth context: remove the const
CURRENT_USER_ID = "mock-user-123", add a prop (e.g., currentUserId: string) to
the BountySidebar component signature (or read currentUserId from your auth
context hook), and use that prop/context value wherever CURRENT_USER_ID is
referenced (all usage sites in this file). Update the parent component to pass
down currentUserId from your auth source, and adjust types to allow string |
undefined if the user may be unauthenticated; ensure any duplicate/eligibility
checks now use the injected currentUserId.
- Around line 132-142: Add an explicit branch for bounty.claimingModel ===
'multi-winner' instead of letting it fall through to the exclusive `/claim`
route: update the label (e.g., set label = 'Claim Bounty (Multi-winner)') and
set endpoint to the multi-winner API route your backend expects (for example
`/api/bounties/${bounty.id}/multi/claim` or the correct multi-winner path), and
ensure this branch appears before the default exclusive claim assignment;
confirm the chosen endpoint matches the server handler for multi-winner claims.
🧹 Nitpick comments (2)
types/bounty.ts (1)

55-60: Prefer a typed milestone/user ID shape over object[].

object[] is too loose and blocks safe property access. Consider a minimal Milestone alias (or interface) plus a UserId alias for clarity.

♻️ Proposed refactor
+export type UserId = string
+export type Milestone = Record<string, unknown>
+
 export interface Bounty {
@@
-  milestones?: object[] // Optional milestone definition
+  milestones?: Milestone[] // Optional milestone definition
@@
-  applicants?: string[]
-  competitors?: string[]
-  members?: string[] // For milestone/collaborative bounties
+  applicants?: UserId[]
+  competitors?: UserId[]
+  members?: UserId[] // For milestone/collaborative bounties
 }
components/bounty/bounty-sidebar.tsx (1)

7-12: Remove commented-out imports to keep the module clean.

They add noise and can drift out of date.

🧹 Cleanup
-import { formatDistanceToNow } from "date-fns"
-// import { cn } from "@/lib/utils"
-// import { useRouter } from "next/navigation" // If we need refresh
+import { formatDistanceToNow } from "date-fns"

@0xdevcollins 0xdevcollins merged commit 6f98010 into boundlessfi:main Jan 30, 2026
2 checks passed
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 Model-Specific Claim/Apply/Join Actions

2 participants