diff --git a/app/api/milestones/[id]/approve/route.ts b/app/api/milestones/[id]/approve/route.ts new file mode 100644 index 0000000..a95cfb7 --- /dev/null +++ b/app/api/milestones/[id]/approve/route.ts @@ -0,0 +1,69 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' + +// Only the contract client can approve (or reject) a submitted milestone +export const POST = withAuth(async (request: NextRequest, auth) => { + const id = request.nextUrl.pathname.split('/').at(-2) + + try { + const body = await request.json().catch(() => ({})) + const { action, rejection_reason } = body // action: 'approve' | 'reject' + + if (!action || !['approve', 'reject'].includes(action)) { + return NextResponse.json( + { error: "Field 'action' must be 'approve' or 'reject'", code: 'MISSING_FIELDS' }, + { status: 400 } + ) + } + + if (action === 'reject' && !rejection_reason) { + return NextResponse.json( + { error: "Field 'rejection_reason' is required when rejecting", code: 'MISSING_FIELDS' }, + { status: 400 } + ) + } + + const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` + if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) + + // Fetch milestone with contract info to verify client role + const [milestone] = await sql` + SELECT m.*, c.client_id + FROM milestones m + LEFT JOIN contracts c ON c.id = m.contract_id + WHERE m.id = ${id} + LIMIT 1 + ` + if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 }) + + if (!milestone.contract_id || milestone.client_id !== user.id) { + return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 }) + } + + if (milestone.status !== 'submitted') { + return NextResponse.json( + { error: `Cannot ${action} a milestone with status '${milestone.status}'`, code: 'INVALID_STATUS' }, + { status: 422 } + ) + } + + const newStatus = action === 'approve' ? 'approved' : 'rejected' + + const [updated] = await sql` + UPDATE milestones SET + status = ${newStatus}, + approved_at = ${action === 'approve' ? sql`NOW()` : null}, + rejection_reason = ${action === 'reject' ? rejection_reason : null}, + updated_at = NOW() + WHERE id = ${id} + RETURNING * + ` + + return NextResponse.json({ milestone: updated }) + } catch { + return NextResponse.json({ error: 'Failed to process milestone approval', code: 'MILESTONE_APPROVE_FAILED' }, { status: 500 }) + } +}) diff --git a/app/api/milestones/[id]/route.ts b/app/api/milestones/[id]/route.ts new file mode 100644 index 0000000..890a7a8 --- /dev/null +++ b/app/api/milestones/[id]/route.ts @@ -0,0 +1,57 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' + +// Only the project client can update a milestone (and only when not yet submitted/approved/paid) +export const PATCH = withAuth(async (request: NextRequest, auth) => { + const id = request.nextUrl.pathname.split('/').at(-1) + + try { + const body = await request.json() + const { title, description, amount, currency, due_date, sort_order, deliverables } = body + + const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` + if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) + + // Fetch milestone and verify ownership via project + const [milestone] = await sql` + SELECT m.*, p.client_id + FROM milestones m + JOIN projects p ON p.id = m.project_id + WHERE m.id = ${id} + LIMIT 1 + ` + if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 }) + if (milestone.client_id !== user.id) { + return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 }) + } + + const immutableStatuses = ['submitted', 'approved', 'paid'] + if (immutableStatuses.includes(milestone.status)) { + return NextResponse.json( + { error: `Cannot update a milestone with status '${milestone.status}'`, code: 'INVALID_STATUS' }, + { status: 422 } + ) + } + + const [updated] = await sql` + UPDATE milestones SET + title = COALESCE(${title ?? null}, title), + description = COALESCE(${description ?? null}, description), + amount = COALESCE(${amount ?? null}, amount), + currency = COALESCE(${currency ?? null}, currency), + due_date = COALESCE(${due_date ?? null}, due_date), + sort_order = COALESCE(${sort_order ?? null}, sort_order), + deliverables = COALESCE(${deliverables ? JSON.stringify(deliverables) : null}, deliverables), + updated_at = NOW() + WHERE id = ${id} + RETURNING * + ` + + return NextResponse.json({ milestone: updated }) + } catch { + return NextResponse.json({ error: 'Failed to update milestone', code: 'MILESTONE_UPDATE_FAILED' }, { status: 500 }) + } +}) diff --git a/app/api/milestones/[id]/submit/route.ts b/app/api/milestones/[id]/submit/route.ts new file mode 100644 index 0000000..fdba1b2 --- /dev/null +++ b/app/api/milestones/[id]/submit/route.ts @@ -0,0 +1,55 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' + +// Only the contract freelancer can submit a milestone (status must be pending or in_progress) +export const POST = withAuth(async (request: NextRequest, auth) => { + const id = request.nextUrl.pathname.split('/').at(-2) + + try { + const body = await request.json().catch(() => ({})) + const { deliverables } = body + + const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` + if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) + + // Fetch milestone with contract info to verify freelancer role + const [milestone] = await sql` + SELECT m.*, c.freelancer_id + FROM milestones m + LEFT JOIN contracts c ON c.id = m.contract_id + WHERE m.id = ${id} + LIMIT 1 + ` + if (!milestone) return NextResponse.json({ error: 'Milestone not found', code: 'MILESTONE_NOT_FOUND' }, { status: 404 }) + + // Must have a contract and caller must be the freelancer + if (!milestone.contract_id || milestone.freelancer_id !== user.id) { + return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 }) + } + + const submittableStatuses = ['pending', 'in_progress'] + if (!submittableStatuses.includes(milestone.status)) { + return NextResponse.json( + { error: `Cannot submit a milestone with status '${milestone.status}'`, code: 'INVALID_STATUS' }, + { status: 422 } + ) + } + + const [updated] = await sql` + UPDATE milestones SET + status = 'submitted', + submitted_at = NOW(), + deliverables = COALESCE(${deliverables ? JSON.stringify(deliverables) : null}, deliverables), + updated_at = NOW() + WHERE id = ${id} + RETURNING * + ` + + return NextResponse.json({ milestone: updated }) + } catch { + return NextResponse.json({ error: 'Failed to submit milestone', code: 'MILESTONE_SUBMIT_FAILED' }, { status: 500 }) + } +}) diff --git a/app/api/milestones/route.ts b/app/api/milestones/route.ts new file mode 100644 index 0000000..34086d0 --- /dev/null +++ b/app/api/milestones/route.ts @@ -0,0 +1,47 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' + +export const POST = withAuth(async (request: NextRequest, auth) => { + try { + const body = await request.json() + const { project_id, title, description, amount, currency, due_date, sort_order, deliverables } = body + + if (!project_id || !title || amount === undefined) { + return NextResponse.json( + { error: 'Missing required fields: project_id, title, amount', code: 'MISSING_FIELDS' }, + { status: 400 } + ) + } + + // Verify caller is the project owner (client) + const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` + if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) + + const [project] = await sql`SELECT id FROM projects WHERE id = ${project_id} AND client_id = ${user.id} LIMIT 1` + if (!project) { + return NextResponse.json({ error: 'Project not found or access denied', code: 'PROJECT_NOT_FOUND' }, { status: 404 }) + } + + const [milestone] = await sql` + INSERT INTO milestones (project_id, title, description, amount, currency, due_date, sort_order, deliverables) + VALUES ( + ${project_id}, + ${title}, + ${description ?? null}, + ${amount}, + ${currency ?? 'USDC'}, + ${due_date ?? null}, + ${sort_order ?? 0}, + ${JSON.stringify(deliverables ?? [])} + ) + RETURNING * + ` + + return NextResponse.json({ milestone }, { status: 201 }) + } catch { + return NextResponse.json({ error: 'Failed to create milestone', code: 'MILESTONE_CREATE_FAILED' }, { status: 500 }) + } +})