Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/api/applications/[id]/review/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
11 changes: 11 additions & 0 deletions app/api/bounties/[id]/applications/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
49 changes: 49 additions & 0 deletions app/api/bounties/[id]/apply/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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 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,
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 });
}
}
55 changes: 55 additions & 0 deletions app/api/bounties/[id]/join/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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 });
}

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);

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 });
}
}
50 changes: 50 additions & 0 deletions app/api/bounties/[id]/milestones/advance/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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 });
}

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);

if (!participation) {
return NextResponse.json({ error: 'Participation not found' }, { status: 404 });
}

let updates: Partial<typeof participation> = {
lastUpdatedAt: new Date().toISOString()
};

if (action === 'advance') {
updates.currentMilestone = participation.currentMilestone + 1;
updates.status = 'advanced';
} else if (action === 'complete') {
updates.status = 'completed';
} else if (action === 'remove') {
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 });
}
}
11 changes: 11 additions & 0 deletions app/api/bounties/[id]/submissions/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
42 changes: 42 additions & 0 deletions app/api/bounties/[id]/submit/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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 bounty = BountyStore.getBountyById(bountyId);
if (!bounty) {
return NextResponse.json({ error: 'Bounty not found' }, { status: 404 });
}

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 });
}
}
35 changes: 35 additions & 0 deletions app/api/submissions/[id]/select/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
77 changes: 77 additions & 0 deletions lib/store.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading