Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
69 changes: 69 additions & 0 deletions app/api/milestones/[id]/approve/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
57 changes: 57 additions & 0 deletions app/api/milestones/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
55 changes: 55 additions & 0 deletions app/api/milestones/[id]/submit/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
47 changes: 47 additions & 0 deletions app/api/milestones/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
})
Loading