Skip to content
Open
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
127 changes: 127 additions & 0 deletions app/api/milestones/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { sql } from '@/lib/db'

export const PATCH = withAuth(async (request: NextRequest, auth) => {
const id = request.nextUrl.pathname.split('/').pop()

if (!id) {
return NextResponse.json({ error: 'Milestone ID is required' }, { status: 400 })
}

// 1. Authenticate user wallet
const userRows = await sql<{ id: string; role: string }[]>`
SELECT id, role FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1
`
if (!userRows.length) {
return NextResponse.json({ error: 'Authenticated user not found in database' }, { status: 404 })
}
const userId = userRows[0].id

// 2. Read state update request
let body;
try {
body = await request.json()
} catch {
return NextResponse.json({ error: 'Invalid JSON request body' }, { status: 400 })
}

const { status } = body
const allowedStatuses = ['pending', 'in_progress', 'submitted', 'approved', 'rejected', 'paid']
if (!status || !allowedStatuses.includes(status)) {
return NextResponse.json({ error: `Invalid milestone status. Must be one of: ${allowedStatuses.join(', ')}` }, { status: 400 })
}

// 3. Find milestone and join with projects & contracts to verify permissions
const milestoneRows = await sql<{
id: string
project_id: string
status: string
client_id: string
freelancer_id: string | null
}[]>`
SELECT
m.id,
m.project_id,
m.status,
p.client_id,
c.freelancer_id
FROM milestones m
JOIN projects p ON p.id = m.project_id
LEFT JOIN contracts c ON c.project_id = p.id
WHERE m.id = ${id}
LIMIT 1
`

if (!milestoneRows.length) {
return NextResponse.json({ error: 'Milestone not found' }, { status: 404 })
}

const milestone = milestoneRows[0]
const isClient = milestone.client_id === userId
const isFreelancer = milestone.freelancer_id === userId

if (!isClient && !isFreelancer) {
return NextResponse.json({ error: 'Unauthorized: You are not a party to this contract' }, { status: 403 })
}

// 4. State transition rules checking (for production safety, while keeping it flexible)
// Freelancer can: start (pending -> in_progress) and submit (in_progress -> submitted)
// Client can: approve (submitted -> approved), reject (submitted -> rejected), and pay (approved -> paid)
if (isFreelancer && !isClient) {
if (status !== 'in_progress' && status !== 'submitted') {
return NextResponse.json({ error: `Forbidden: As a freelancer, you cannot set status to ${status}` }, { status: 403 })
}
}

if (isClient && !isFreelancer) {
if (status !== 'approved' && status !== 'rejected' && status !== 'paid') {
return NextResponse.json({ error: `Forbidden: As a client, you cannot set status to ${status}` }, { status: 403 })
}
}

// 5. Build dynamic query for updates, adding dates where applicable
let updatedRows;

if (status === 'submitted') {
updatedRows = await sql<any[]>`
UPDATE milestones
SET status = ${status}, submitted_at = NOW(), updated_at = NOW()
WHERE id = ${id}
RETURNING *
`
} else if (status === 'approved') {
updatedRows = await sql<any[]>`
UPDATE milestones
SET status = ${status}, approved_at = NOW(), updated_at = NOW()
WHERE id = ${id}
RETURNING *
`
} else if (status === 'paid') {
updatedRows = await sql<any[]>`
UPDATE milestones
SET status = ${status}, paid_at = NOW(), updated_at = NOW()
WHERE id = ${id}
RETURNING *
`
} else {
// pending, in_progress, rejected
updatedRows = await sql<any[]>`
UPDATE milestones
SET status = ${status}, updated_at = NOW()
WHERE id = ${id}
RETURNING *
`
}

if (!updatedRows.length) {
return NextResponse.json({ error: 'Failed to update milestone' }, { status: 500 })
}

return NextResponse.json({
success: true,
milestone: updatedRows[0]
})
})
104 changes: 49 additions & 55 deletions app/dashboard/projects/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
EscrowStatusTracker,
type EscrowStage,
} from "@/components/dashboard/escrow-status-tracker";
import { MilestoneProgressTracker } from "@/components/dashboard/milestone-progress-tracker";

interface Milestone {
id: string;
Expand Down Expand Up @@ -79,6 +80,48 @@ export default function ProjectDetailPage() {
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
const [now] = useState(() => Date.now());

const handleMilestoneStatusUpdate = async (milestoneId: string, newStatus: string) => {
try {
const res = await fetch(`/api/milestones/${milestoneId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || "Failed to update milestone status");
}

// Update local state dynamically
setMilestones((prev) =>
prev.map((m) =>
m.id === milestoneId
? {
...m,
status: newStatus,
}
: m
)
);

// Trigger standard API re-fetch for absolute sync
const resProj = await fetch(`/api/projects/${id}`, {
headers: getAuthHeaders(),
});
if (resProj.ok) {
const data = await resProj.json();
setProject(data.project);
setMilestones(data.milestones ?? []);
}
} catch (err: any) {
console.error(err);
alert(err.message || "Failed to update milestone status. Make sure you are authorized.");
}
};

useEffect(() => {
if (!id) return;
(async () => {
Expand Down Expand Up @@ -202,61 +245,12 @@ export default function ProjectDetailPage() {
</TabsList>

<TabsContent value="milestones" className="space-y-4 mt-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold">Project Milestones</h2>
<p className="text-sm text-muted-foreground mt-1">
{completedMilestones} of {milestones.length} completed
</p>
</div>
{project.status === "in_progress" && (
<Button onClick={() => setShowApprovalDialog(true)} className="group">
<CheckCircle2 className="mr-2 h-4 w-4 group-hover:scale-110 transition-transform" />
Approve All
</Button>
)}
</div>

{milestones.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">
No milestones for this project.
</p>
) : (
<div className="space-y-3">
{milestones.map((m, i) => {
const mCfg = milestoneStatusConfig[m.status] ?? milestoneStatusConfig.pending;
return (
<div
key={m.id}
className="flex items-start justify-between p-4 rounded-lg border border-border/40 bg-card/50"
>
<div className="flex items-start gap-3 flex-1">
<div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-bold text-primary shrink-0 mt-0.5">
{i + 1}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold">{m.title}</p>
{m.description && (
<p className="text-sm text-muted-foreground mt-0.5">{m.description}</p>
)}
{m.due_date && (
<p className="text-xs text-muted-foreground mt-1">
Due {new Date(m.due_date).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="text-right shrink-0 ml-4">
<p className="font-semibold">
${parseFloat(m.amount).toLocaleString()} {m.currency}
</p>
<p className={`text-xs mt-1 ${mCfg.color}`}>{mCfg.label}</p>
</div>
</div>
);
})}
</div>
)}
<MilestoneProgressTracker
milestones={milestones}
onStatusUpdate={handleMilestoneStatusUpdate}
userRole="client"
contractAddress={project.contract_address || undefined}
/>
</TabsContent>

<TabsContent value="activity" className="space-y-4 mt-6">
Expand Down
Loading
Loading