From 5816ce3d97db2bf656dbdd2f9e3fb24c0cfd7df0 Mon Sep 17 00:00:00 2001 From: Michaelkingsdev Date: Thu, 29 Jan 2026 01:21:32 +0100 Subject: [PATCH 1/4] feat: Implementation of bounty participation & submission backend logic --- app/api/applications/[id]/review/route.ts | 34 ++++++++ app/api/bounties/[id]/applications/route.ts | 11 +++ app/api/bounties/[id]/apply/route.ts | 36 +++++++++ app/api/bounties/[id]/join/route.ts | 46 +++++++++++ .../bounties/[id]/milestones/advance/route.ts | 49 ++++++++++++ app/api/bounties/[id]/submissions/route.ts | 11 +++ app/api/bounties/[id]/submit/route.ts | 37 +++++++++ app/api/submissions/[id]/select/route.ts | 35 +++++++++ lib/store.test.ts | 77 +++++++++++++++++++ lib/store.ts | 65 ++++++++++++++++ types/bounty.ts | 2 +- types/participation.ts | 41 ++++++++++ 12 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 app/api/applications/[id]/review/route.ts create mode 100644 app/api/bounties/[id]/applications/route.ts create mode 100644 app/api/bounties/[id]/apply/route.ts create mode 100644 app/api/bounties/[id]/join/route.ts create mode 100644 app/api/bounties/[id]/milestones/advance/route.ts create mode 100644 app/api/bounties/[id]/submissions/route.ts create mode 100644 app/api/bounties/[id]/submit/route.ts create mode 100644 app/api/submissions/[id]/select/route.ts create mode 100644 lib/store.test.ts create mode 100644 lib/store.ts create mode 100644 types/participation.ts diff --git a/app/api/applications/[id]/review/route.ts b/app/api/applications/[id]/review/route.ts new file mode 100644 index 0000000..6c518a7 --- /dev/null +++ b/app/api/applications/[id]/review/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { ApplicationStatus } from '@/types/participation'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: appId } = await params; + + try { + const body = await request.json(); + const { status, feedback } = body; + + if (!status || !['approved', 'rejected'].includes(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); + } + + const updatedApp = BountyStore.updateApplication(appId, { + status: status as ApplicationStatus, + feedback, + reviewedAt: new Date().toISOString() + }); + + if (!updatedApp) { + return NextResponse.json({ error: 'Application not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: updatedApp }); + + } catch (error) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/bounties/[id]/applications/route.ts b/app/api/bounties/[id]/applications/route.ts new file mode 100644 index 0000000..aeb8c10 --- /dev/null +++ b/app/api/bounties/[id]/applications/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + const applications = BountyStore.getApplicationsByBounty(bountyId); + return NextResponse.json({ data: applications }); +} diff --git a/app/api/bounties/[id]/apply/route.ts b/app/api/bounties/[id]/apply/route.ts new file mode 100644 index 0000000..254a50c --- /dev/null +++ b/app/api/bounties/[id]/apply/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { Application } from '@/types/participation'; + +const generateId = () => crypto.randomUUID(); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + try { + const body = await request.json(); + const { applicantId, coverLetter, portfolioUrl } = body; + + if (!applicantId || !coverLetter) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const application: Application = { + id: generateId(), + bountyId: bountyId, + applicantId, + coverLetter, + portfolioUrl, + status: 'pending', + submittedAt: new Date().toISOString(), + }; + + BountyStore.addApplication(application); + + return NextResponse.json({ success: true, data: application }); + } catch (error) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/bounties/[id]/join/route.ts b/app/api/bounties/[id]/join/route.ts new file mode 100644 index 0000000..2b73fbf --- /dev/null +++ b/app/api/bounties/[id]/join/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { MilestoneParticipation } from '@/types/participation'; + +const generateId = () => crypto.randomUUID(); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + + try { + const body = await request.json(); + const { contributorId } = body; + + if (!contributorId) { + return NextResponse.json({ error: 'Missing contributorId' }, { status: 400 }); + } + + // Check if already joined + const existing = BountyStore.getMilestoneParticipationsByBounty(bountyId) + .find(p => p.contributorId === contributorId); + + if (existing) { + return NextResponse.json({ error: 'Already joined this bounty' }, { status: 409 }); + } + + const participation: MilestoneParticipation = { + id: generateId(), + bountyId, + contributorId, + currentMilestone: 1, // Start at milestone 1 + status: 'active', + joinedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString() + }; + + BountyStore.addMilestoneParticipation(participation); + + return NextResponse.json({ success: true, data: participation }); + + } catch (error) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts new file mode 100644 index 0000000..646c4e8 --- /dev/null +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { MilestoneStatus } from '@/types/participation'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + + try { + const body = await request.json(); + const { contributorId, action } = body; // action: 'advance' | 'complete' | 'remove' + + if (!contributorId || !action) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const participations = BountyStore.getMilestoneParticipationsByBounty(bountyId); + const participation = participations.find(p => p.contributorId === contributorId); + + if (!participation) { + return NextResponse.json({ error: 'Participation not found' }, { status: 404 }); + } + + let updates: Partial = { + lastUpdatedAt: new Date().toISOString() + }; + + if (action === 'advance') { + updates.currentMilestone = participation.currentMilestone + 1; + updates.status = 'advanced'; // Or keep 'active'? Using 'advanced' to signal state change + } else if (action === 'complete') { + updates.status = 'completed'; + } else if (action === 'remove') { + // In a real DB we might delete or set status to dropped + updates.status = 'active'; // Reset or specific status? Let's assume there is no 'dropped' yet, but usually we would have one. + // For now let's just not support remove via this specific endpoint or add a status. + return NextResponse.json({ error: 'Remove action not supported yet' }, { status: 400 }); + } + + const updated = BountyStore.updateMilestoneParticipation(participation.id, updates); + + return NextResponse.json({ success: true, data: updated }); + + } catch (error) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/bounties/[id]/submissions/route.ts b/app/api/bounties/[id]/submissions/route.ts new file mode 100644 index 0000000..9d0293a --- /dev/null +++ b/app/api/bounties/[id]/submissions/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + const submissions = BountyStore.getSubmissionsByBounty(bountyId); + return NextResponse.json({ data: submissions }); +} diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts new file mode 100644 index 0000000..9289f20 --- /dev/null +++ b/app/api/bounties/[id]/submit/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { Submission } from '@/types/participation'; + +const generateId = () => crypto.randomUUID(); + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: bountyId } = await params; + + try { + const body = await request.json(); + const { contributorId, content } = body; + + if (!contributorId || !content) { + return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); + } + + const submission: Submission = { + id: generateId(), + bountyId, + contributorId, + content, + status: 'pending', + submittedAt: new Date().toISOString(), + }; + + BountyStore.addSubmission(submission); + + return NextResponse.json({ success: true, data: submission }); + + } catch (error) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/app/api/submissions/[id]/select/route.ts b/app/api/submissions/[id]/select/route.ts new file mode 100644 index 0000000..7a6ff64 --- /dev/null +++ b/app/api/submissions/[id]/select/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from 'next/server'; +import { BountyStore } from '@/lib/store'; +import { SubmissionStatus } from '@/types/participation'; + +export async function POST( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id: subId } = await params; + + try { + const body = await request.json(); + // 'accepted' implies winner + const { status, feedback } = body; + + if (!status || !['accepted', 'rejected'].includes(status)) { + return NextResponse.json({ error: 'Invalid status' }, { status: 400 }); + } + + const updatedSub = BountyStore.updateSubmission(subId, { + status: status as SubmissionStatus, + feedback, + reviewedAt: new Date().toISOString() + }); + + if (!updatedSub) { + return NextResponse.json({ error: 'Submission not found' }, { status: 404 }); + } + + return NextResponse.json({ success: true, data: updatedSub }); + + } catch (error) { + return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 }); + } +} diff --git a/lib/store.test.ts b/lib/store.test.ts new file mode 100644 index 0000000..782247f --- /dev/null +++ b/lib/store.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { BountyStore } from './store'; +import { Application, Submission, MilestoneParticipation } from '@/types/participation'; + +describe('BountyStore', () => { + // Note: Since BountyStore uses a global singleton, state might persist. + // Ideally we'd have a reset method, but for this basic verification we'll assume clean state or manage it. + // However, unit tests in Vitest usually run in isolation per file, but global state might persist if not reset. + // For now, let's just test distinct IDs. + + describe('Model 2: Applications', () => { + it('should add and retrieve an application', () => { + const app: Application = { + id: 'app-1', + bountyId: 'b-1', + applicantId: 'u-1', + coverLetter: 'Hire me', + status: 'pending', + submittedAt: new Date().toISOString() + }; + BountyStore.addApplication(app); + const retrieved = BountyStore.getApplicationById('app-1'); + expect(retrieved).toEqual(app); + const list = BountyStore.getApplicationsByBounty('b-1'); + expect(list).toHaveLength(1); + }); + + it('should update application status', () => { + const updated = BountyStore.updateApplication('app-1', { status: 'approved' }); + expect(updated?.status).toBe('approved'); + expect(BountyStore.getApplicationById('app-1')?.status).toBe('approved'); + }); + }); + + describe('Model 3: Submissions', () => { + it('should add and retrieve a submission', () => { + const sub: Submission = { + id: 'sub-1', + bountyId: 'b-2', + contributorId: 'u-2', + content: 'My work', + status: 'pending', + submittedAt: new Date().toISOString() + }; + BountyStore.addSubmission(sub); + expect(BountyStore.getSubmissionById('sub-1')).toEqual(sub); + }); + + it('should update submission status', () => { + BountyStore.updateSubmission('sub-1', { status: 'accepted' }); + expect(BountyStore.getSubmissionById('sub-1')?.status).toBe('accepted'); + }); + }); + + describe('Model 4: Milestones', () => { + it('should join a milestone', () => { + const mp: MilestoneParticipation = { + id: 'mp-1', + bountyId: 'b-3', + contributorId: 'u-3', + currentMilestone: 1, + status: 'active', + joinedAt: new Date().toISOString(), + lastUpdatedAt: new Date().toISOString() + }; + BountyStore.addMilestoneParticipation(mp); + const list = BountyStore.getMilestoneParticipationsByBounty('b-3'); + expect(list).toHaveLength(1); + }); + + it('should advance a milestone', () => { + BountyStore.updateMilestoneParticipation('mp-1', { currentMilestone: 2 }); + const mp = BountyStore.getMilestoneParticipationsByBounty('b-3').find((p: MilestoneParticipation) => p.id === 'mp-1'); + expect(mp?.currentMilestone).toBe(2); + }); + }); +}); diff --git a/lib/store.ts b/lib/store.ts new file mode 100644 index 0000000..79e9eaa --- /dev/null +++ b/lib/store.ts @@ -0,0 +1,65 @@ +import { Bounty } from "@/types/bounty"; +import { Application, Submission, MilestoneParticipation } from "@/types/participation"; +import { mockBounties } from "./mock-bounty"; + +class BountyStoreData { + bounties: Bounty[] = [...mockBounties]; + applications: Application[] = []; + submissions: Submission[] = []; + milestoneParticipations: MilestoneParticipation[] = []; +} + +const globalStore: BountyStoreData = (global as any).bountyStore || new BountyStoreData(); +if (process.env.NODE_ENV !== 'production') (global as any).bountyStore = globalStore; + +export const BountyStore = { + // Bounties + getBounties: () => globalStore.bounties, + getBountyById: (id: string) => globalStore.bounties.find((b: Bounty) => b.id === id), + + // Applications (Model 2) + addApplication: (app: Application) => { + globalStore.applications.push(app); + return app; + }, + getApplicationsByBounty: (bountyId: string) => + globalStore.applications.filter((a: Application) => a.bountyId === bountyId), + getApplicationById: (appId: string) => + globalStore.applications.find((a: Application) => a.id === appId), + updateApplication: (appId: string, updates: Partial) => { + const index = globalStore.applications.findIndex((a: Application) => a.id === appId); + if (index === -1) return null; + globalStore.applications[index] = { ...globalStore.applications[index], ...updates }; + return globalStore.applications[index]; + }, + + // Submissions (Model 3) + addSubmission: (sub: Submission) => { + globalStore.submissions.push(sub); + return sub; + }, + getSubmissionsByBounty: (bountyId: string) => + globalStore.submissions.filter((s: Submission) => s.bountyId === bountyId), + getSubmissionById: (subId: string) => + globalStore.submissions.find((s: Submission) => s.id === subId), + updateSubmission: (subId: string, updates: Partial) => { + const index = globalStore.submissions.findIndex((s: Submission) => s.id === subId); + if (index === -1) return null; + globalStore.submissions[index] = { ...globalStore.submissions[index], ...updates }; + return globalStore.submissions[index]; + }, + + // Milestones (Model 4) + addMilestoneParticipation: (mp: MilestoneParticipation) => { + globalStore.milestoneParticipations.push(mp); + return mp; + }, + getMilestoneParticipationsByBounty: (bountyId: string) => + globalStore.milestoneParticipations.filter((m: MilestoneParticipation) => m.bountyId === bountyId), + updateMilestoneParticipation: (participationId: string, updates: Partial) => { + const index = globalStore.milestoneParticipations.findIndex((m: MilestoneParticipation) => m.id === participationId); + if (index === -1) return null; + globalStore.milestoneParticipations[index] = { ...globalStore.milestoneParticipations[index], ...updates }; + return globalStore.milestoneParticipations[index]; + } +}; diff --git a/types/bounty.ts b/types/bounty.ts index e896c6a..ca84d21 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -5,7 +5,7 @@ export type BountyType = | 'refactor' | 'other' -export type ClaimingModel = 'single-claim' | 'application' | 'competition' | 'multi-winner' +export type ClaimingModel = 'single-claim' | 'application' | 'competition' | 'multi-winner' | 'milestone' export interface Bounty { id: string diff --git a/types/participation.ts b/types/participation.ts new file mode 100644 index 0000000..42eb379 --- /dev/null +++ b/types/participation.ts @@ -0,0 +1,41 @@ +export type ApplicationStatus = 'pending' | 'approved' | 'rejected' + +export interface Application { + id: string + bountyId: string + applicantId: string + applicantName?: string // Optional for UI convenience + coverLetter: string + portfolioUrl?: string + status: ApplicationStatus + submittedAt: string + reviewedAt?: string + feedback?: string +} + +export type SubmissionStatus = 'pending' | 'accepted' | 'rejected' + +export interface Submission { + id: string + bountyId: string + contributorId: string + contributorName?: string + content: string // URL or text description + status: SubmissionStatus + submittedAt: string + reviewedAt?: string + feedback?: string +} + +export type MilestoneStatus = 'active' | 'completed' | 'advanced' + +export interface MilestoneParticipation { + id: string + bountyId: string + contributorId: string + contributorName?: string + currentMilestone: number // 1-based index + status: MilestoneStatus + joinedAt: string + lastUpdatedAt: string +} From 13ccf4f044849d91d1501006cf85955ef81a35ab Mon Sep 17 00:00:00 2001 From: Michaelkingsdev Date: Thu, 29 Jan 2026 07:18:35 +0100 Subject: [PATCH 2/4] fix: implemented coderabbit corrections --- app/api/bounties/[id]/apply/route.ts | 13 +++++++++++++ app/api/bounties/[id]/join/route.ts | 9 +++++++++ app/api/bounties/[id]/milestones/advance/route.ts | 11 ++++++----- app/api/bounties/[id]/submit/route.ts | 5 +++++ lib/store.ts | 8 ++++++-- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/app/api/bounties/[id]/apply/route.ts b/app/api/bounties/[id]/apply/route.ts index 254a50c..446b2c3 100644 --- a/app/api/bounties/[id]/apply/route.ts +++ b/app/api/bounties/[id]/apply/route.ts @@ -17,6 +17,19 @@ export async function POST( return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); + } + + const existingApplication = BountyStore.getApplicationsByBounty(bountyId).find( + (app) => app.applicantId === applicantId + ); + + if (existingApplication) { + return NextResponse.json({ error: 'Application already exists' }, { status: 409 }); + } + const application: Application = { id: generateId(), bountyId: bountyId, diff --git a/app/api/bounties/[id]/join/route.ts b/app/api/bounties/[id]/join/route.ts index 2b73fbf..4536204 100644 --- a/app/api/bounties/[id]/join/route.ts +++ b/app/api/bounties/[id]/join/route.ts @@ -18,6 +18,15 @@ export async function POST( return NextResponse.json({ error: 'Missing contributorId' }, { status: 400 }); } + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); + } + + if (bounty.claimingModel !== 'milestone') { + return NextResponse.json({ error: 'Invalid claiming model' }, { status: 400 }); + } + // Check if already joined const existing = BountyStore.getMilestoneParticipationsByBounty(bountyId) .find(p => p.contributorId === contributorId); diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts index 646c4e8..b63d55b 100644 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { BountyStore } from '@/lib/store'; -import { MilestoneStatus } from '@/types/participation'; +// import { MilestoneStatus } from '@/types/participation'; export async function POST( request: Request, @@ -16,6 +16,10 @@ export async function POST( return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + if (!['advance', 'complete', 'remove'].includes(action)) { + return NextResponse.json({ error: 'Invalid action' }, { status: 400 }); + } + const participations = BountyStore.getMilestoneParticipationsByBounty(bountyId); const participation = participations.find(p => p.contributorId === contributorId); @@ -29,13 +33,10 @@ export async function POST( if (action === 'advance') { updates.currentMilestone = participation.currentMilestone + 1; - updates.status = 'advanced'; // Or keep 'active'? Using 'advanced' to signal state change + updates.status = 'advanced'; } else if (action === 'complete') { updates.status = 'completed'; } else if (action === 'remove') { - // In a real DB we might delete or set status to dropped - updates.status = 'active'; // Reset or specific status? Let's assume there is no 'dropped' yet, but usually we would have one. - // For now let's just not support remove via this specific endpoint or add a status. return NextResponse.json({ error: 'Remove action not supported yet' }, { status: 400 }); } diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index 9289f20..ec26fde 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -18,6 +18,11 @@ export async function POST( return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); } + const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); + } + const submission: Submission = { id: generateId(), bountyId, diff --git a/lib/store.ts b/lib/store.ts index 79e9eaa..e742039 100644 --- a/lib/store.ts +++ b/lib/store.ts @@ -9,8 +9,12 @@ class BountyStoreData { milestoneParticipations: MilestoneParticipation[] = []; } -const globalStore: BountyStoreData = (global as any).bountyStore || new BountyStoreData(); -if (process.env.NODE_ENV !== 'production') (global as any).bountyStore = globalStore; +declare global { + var bountyStore: BountyStoreData | undefined; +} + +const globalStore: BountyStoreData = globalThis.bountyStore || new BountyStoreData(); +if (process.env.NODE_ENV !== 'production') globalThis.bountyStore = globalStore; export const BountyStore = { // Bounties From c7645106d1ef9f3acc17763a281690976a05a9b0 Mon Sep 17 00:00:00 2001 From: Michaelkingsdev Date: Thu, 29 Jan 2026 07:25:15 +0100 Subject: [PATCH 3/4] fix: implemented coderabbit corrections --- app/api/bounties/[id]/milestones/advance/route.ts | 13 +++++++++++++ app/api/bounties/[id]/submit/route.ts | 13 +++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts index b63d55b..ca27d68 100644 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -27,14 +27,27 @@ export async function POST( return NextResponse.json({ error: 'Participation not found' }, { status: 404 }); } + const bounty = BountyStore.getBountyById(bountyId); + let updates: Partial = { lastUpdatedAt: new Date().toISOString() }; + const totalMilestones = (participation as any).totalMilestones || (bounty as any).milestones?.length; + if (action === 'advance') { + if (participation.status === 'completed') { + return NextResponse.json({ error: 'Cannot advance completed participation' }, { status: 409 }); + } + if (totalMilestones && participation.currentMilestone >= totalMilestones) { + return NextResponse.json({ error: 'Already at last milestone' }, { status: 409 }); + } updates.currentMilestone = participation.currentMilestone + 1; updates.status = 'advanced'; } else if (action === 'complete') { + if (participation.status === 'completed') { + return NextResponse.json({ error: 'Already completed' }, { status: 409 }); + } updates.status = 'completed'; } else if (action === 'remove') { return NextResponse.json({ error: 'Remove action not supported yet' }, { status: 400 }); diff --git a/app/api/bounties/[id]/submit/route.ts b/app/api/bounties/[id]/submit/route.ts index ec26fde..ed09d0f 100644 --- a/app/api/bounties/[id]/submit/route.ts +++ b/app/api/bounties/[id]/submit/route.ts @@ -23,6 +23,19 @@ export async function POST( return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); } + 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 }); + } + + const existingSubmission = BountyStore.getSubmissionsByBounty(bountyId).find( + s => s.contributorId === contributorId + ); + + if (existingSubmission) { + return NextResponse.json({ error: 'Duplicate submission' }, { status: 409 }); + } + const submission: Submission = { id: generateId(), bountyId, From 14a670e8195f600fda989eeec053c13fad1bb3ae Mon Sep 17 00:00:00 2001 From: Michaelkingsdev Date: Thu, 29 Jan 2026 07:30:54 +0100 Subject: [PATCH 4/4] fix: Avoid any casts and add a null check for bounty --- app/api/bounties/[id]/milestones/advance/route.ts | 11 +++++++++-- types/bounty.ts | 1 + types/participation.ts | 1 + 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/api/bounties/[id]/milestones/advance/route.ts b/app/api/bounties/[id]/milestones/advance/route.ts index ca27d68..e9229b3 100644 --- a/app/api/bounties/[id]/milestones/advance/route.ts +++ b/app/api/bounties/[id]/milestones/advance/route.ts @@ -29,17 +29,24 @@ export async function POST( const bounty = BountyStore.getBountyById(bountyId); + if (!bounty) { + return NextResponse.json({ error: 'Bounty not found' }, { status: 404 }); + } + let updates: Partial = { lastUpdatedAt: new Date().toISOString() }; - const totalMilestones = (participation as any).totalMilestones || (bounty as any).milestones?.length; + const totalMilestones = participation.totalMilestones || bounty.milestones?.length; if (action === 'advance') { if (participation.status === 'completed') { return NextResponse.json({ error: 'Cannot advance completed participation' }, { status: 409 }); } - if (totalMilestones && participation.currentMilestone >= totalMilestones) { + if (!totalMilestones) { + return NextResponse.json({ error: 'Cannot determine total milestones' }, { status: 500 }); + } + if (participation.currentMilestone >= totalMilestones) { return NextResponse.json({ error: 'Already at last milestone' }, { status: 409 }); } updates.currentMilestone = participation.currentMilestone + 1; diff --git a/types/bounty.ts b/types/bounty.ts index ca84d21..db40f5a 100644 --- a/types/bounty.ts +++ b/types/bounty.ts @@ -46,6 +46,7 @@ export interface Bounty { // Let's add them as optional to be safe and backward compatible with existing components. requirements?: string[] scope?: string + milestones?: any[] // Optional milestone definition } export type BountyStatus = Bounty['status'] diff --git a/types/participation.ts b/types/participation.ts index 42eb379..9d8a042 100644 --- a/types/participation.ts +++ b/types/participation.ts @@ -38,4 +38,5 @@ export interface MilestoneParticipation { status: MilestoneStatus joinedAt: string lastUpdatedAt: string + totalMilestones?: number // Optional override or cached value }