feat: Implement Model-Specific Claim/Apply/Join Actions#60
feat: Implement Model-Specific Claim/Apply/Join Actions#600xdevcollins merged 7 commits intoboundlessfi:mainfrom
Conversation
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 unusedbountyIdprop to clear the lint warning.Static analysis reports
bountyIdis unused here; either drop it (and updatecomponents/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 unusedcnimport to satisfy the lint warning.🧹 Cleanup
-import { cn } from "@/lib/utils"
49-67: Preferrouter.refresh()over fullwindow.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()
There was a problem hiding this comment.
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: RemovecontributorIdfrom the apply flow payload.The apply endpoint expects
applicantId,coverLetter, andportfolioUrl—notcontributorId. Line 55 injectscontributorIdinto 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. RefactorhandleActionto 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 namedMilestonetype instead ofobject[].The
milestonesfield is used in the codebase (e.g., accessing.lengthin the milestone advancement API), but remains weakly typed asobject[]. 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 }
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_IDand 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.
There was a problem hiding this comment.
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 overobject[].
object[]is too loose and blocks safe property access. Consider a minimalMilestonealias (or interface) plus aUserIdalias 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"
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:
Changes
Frontend
components/bounty/bounty-sidebar.tsxCompletely refactored to check
bounty.claimingModeland render the appropriate action button (Claim, Apply, Join, etc.) instead of a static placeholder.components/bounty/application-dialog.tsxNew component. A modal dialog for users to submit applications with a cover letter.
Backend API
app/api/bounties/[id]/claim/route.tsNew endpoint. Handles exclusivity locking for single-claim bounties.
app/api/bounties/[id]/competition/join/route.tsNew endpoint. Registers users for competition bounties.
app/api/bounties/[id]/apply/route.tsIntegrated with frontend.
app/api/bounties/[id]/join/route.tsIntegrated with frontend.
Data Layer
lib/store.tsAdded
competitionParticipationscollection and helper methods.types/participation.tsAdded
CompetitionParticipationinterface.types/bounty.tsAdded logic fields (
claimedBy,claimedAt,claimExpiresAt) for tracking bounty status.Verification
POST /claimupdates status to claimed and sets expiry. UI reflects "Already Claimed".Checklist
Summary by CodeRabbit
New Features
Updates
Refactor
✏️ Tip: You can customize this high-level summary in your review settings.