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
195 changes: 195 additions & 0 deletions app/api/proposals/[id]/accept/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// app/api/proposals/[id]/accept/route.ts
//
// POST /api/proposals/:id/accept
//
// Accepts a freelancer's proposal and atomically creates:
// - A contract record (status='pending', escrow_status='unfunded')
// - Milestone rows from the proposal's milestone_breakdown
// - An audit log entry
//
// Authorization:
// - Caller must be authenticated (Bearer JWT via withAuthCtx).
// - Caller must be the client who owns the project.
// - Freelancers cannot accept proposals.
//
// Error codes returned:
// 400 INVALID_UUID — proposal ID in path is not a valid UUID
// 401 AUTH_REQUIRED — missing or invalid JWT
// 403 FORBIDDEN — caller is not the project client
// 404 PROPOSAL_NOT_FOUND — no proposal with that ID
// 409 PROPOSAL_ALREADY_ACCEPTED — proposal was already accepted
// 409 PROPOSAL_NOT_PENDING — proposal is in a non-acceptable state
// 409 PROJECT_NOT_ACTIVE — project is not open / in_progress
// 422 INVALID_REQUEST_BODY — body fails schema validation
// 500 SERVER_ERROR — unexpected failure

export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { withAuthCtx } from '@/lib/auth/middleware'
import { sql } from '@/lib/db'
import { AcceptProposalSchema } from '@/lib/validations'
import {
acceptProposal,
ProposalNotFoundError,
ProposalAlreadyAcceptedError,
ProposalNotPendingError,
ProjectNotActiveError,
UnauthorizedAcceptanceError,
} from '@/lib/proposals'

// ─── Route context type (Next.js App Router) ──────────────────────────────

interface RouteContext {
params: Promise<{ id: string }>
}

// ─── ID validation ────────────────────────────────────────────────────────

const UUIDSchema = z.string().uuid()

// ─── POST handler ─────────────────────────────────────────────────────────

export const POST = withAuthCtx<RouteContext>(
async (request: NextRequest, auth, context: RouteContext) => {
// 1. Extract and validate proposal ID from path
const { id: rawId } = await context.params
const idResult = UUIDSchema.safeParse(rawId)
if (!idResult.success) {
return NextResponse.json(
{ error: 'Proposal ID must be a valid UUID', code: 'INVALID_UUID' },
{ status: 400 },
)
}
const proposalId = idResult.data

// 2. Parse request body (body is optional for this endpoint)
let body: unknown = {}
const contentType = request.headers.get('content-type') ?? ''
if (contentType.includes('application/json')) {
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
{ status: 400 },
)
}
}

const parsed = AcceptProposalSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{
error: 'Validation failed',
code: 'INVALID_REQUEST_BODY',
details: parsed.error.flatten().fieldErrors,
},
{ status: 422 },
)
}

// 3. Resolve the authenticated user's DB id from their wallet address
const userRows = (await sql`
SELECT id, role
FROM users
WHERE wallet_address = ${auth.walletAddress}
LIMIT 1
`) as Array<{ id: string; role: string }>

if (userRows.length === 0) {
return NextResponse.json(
{ error: 'Authenticated user not found', code: 'USER_NOT_FOUND' },
{ status: 401 },
)
}

const { id: callerId, role: callerRole } = userRows[0]

// 4. Only clients (or admins) may accept proposals
if (callerRole !== 'client' && callerRole !== 'admin') {
return NextResponse.json(
{
error: 'Only clients can accept proposals',
code: 'FORBIDDEN',
},
{ status: 403 },
)
}

// 5. Run the atomic acceptance flow
try {
const result = await acceptProposal(proposalId, callerId)

return NextResponse.json(
{
message: 'Proposal accepted and contract created',
proposal: result.proposal,
contract: result.contract,
milestones: result.milestones,
},
{ status: 201 },
)
} catch (err) {
// ── Known domain errors → deterministic HTTP responses ──────────────

if (err instanceof ProposalNotFoundError) {
return NextResponse.json(
{ error: err.message, code: 'PROPOSAL_NOT_FOUND' },
{ status: 404 },
)
}

if (err instanceof ProposalAlreadyAcceptedError) {
return NextResponse.json(
{ error: err.message, code: 'PROPOSAL_ALREADY_ACCEPTED' },
{ status: 409 },
)
}

if (err instanceof ProposalNotPendingError) {
return NextResponse.json(
{ error: err.message, code: 'PROPOSAL_NOT_PENDING' },
{ status: 409 },
)
}

if (err instanceof ProjectNotActiveError) {
return NextResponse.json(
{ error: err.message, code: 'PROJECT_NOT_ACTIVE' },
{ status: 409 },
)
}

if (err instanceof UnauthorizedAcceptanceError) {
return NextResponse.json(
{ error: err.message, code: 'FORBIDDEN' },
{ status: 403 },
)
}

// ── DB unique constraint violation on uq_proposals_one_accepted_per_project
// This is a last-resort guard if two concurrent requests race through ──
if (
err instanceof Error &&
err.message.includes('uq_proposals_one_accepted_per_project')
) {
return NextResponse.json(
{
error: 'Another proposal has already been accepted for this project',
code: 'PROJECT_ALREADY_HAS_ACCEPTED_PROPOSAL',
},
{ status: 409 },
)
}

// ── Unexpected error ─────────────────────────────────────────────────
console.error('[POST /api/proposals/[id]/accept]', err)
return NextResponse.json(
{ error: 'Failed to accept proposal', code: 'SERVER_ERROR' },
{ status: 500 },
)
}
},
)
62 changes: 62 additions & 0 deletions lib/validations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,65 @@ export type UpdateMilestoneInput = z.infer<typeof UpdateMilestoneSchema>
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>
export type UserProfileInput = z.infer<typeof UserProfileSchema>
export type WalletSignatureInput = z.infer<typeof WalletSignatureSchema>

// ─── Proposal ─────────────────────────────────────────────────────────────

export const PROPOSAL_STATUSES = ['pending', 'accepted', 'rejected', 'withdrawn'] as const
export const ProposalStatusSchema = z.enum(PROPOSAL_STATUSES)

/** Shape of one milestone entry inside proposal.milestoneBreakdown */
export const ProposalMilestoneSchema = z.object({
title: z
.string({ required_error: 'milestone title is required' })
.min(1, 'milestone title cannot be empty')
.max(200, 'milestone title must be 200 characters or fewer'),
description: z
.string()
.max(2000, 'milestone description must be 2000 characters or fewer')
.optional(),
amount: z
.number({ required_error: 'milestone amount is required' })
.positive('milestone amount must be positive'),
due_date: z.coerce.date().optional(),
})

export const CreateProposalSchema = z.object({
projectId: z
.string({ required_error: 'projectId is required' })
.uuid('projectId must be a valid UUID'),
coverLetter: z
.string({ required_error: 'coverLetter is required' })
.min(10, 'coverLetter must be at least 10 characters')
.max(5000, 'coverLetter must be 5000 characters or fewer'),
proposedAmount: z
.number({ required_error: 'proposedAmount is required' })
.positive('proposedAmount must be positive'),
currency: z.enum(['XLM', 'USDC']).default('USDC'),
estimatedDuration: z
.string()
.max(100, 'estimatedDuration must be 100 characters or fewer')
.optional(),
milestoneBreakdown: z
.array(ProposalMilestoneSchema)
.max(20, 'milestone breakdown cannot exceed 20 milestones')
.optional()
.default([]),
})

/**
* Body accepted by POST /api/proposals/[id]/accept.
*
* Currently the proposal ID comes from the URL path, so the body may be
* empty or carry optional metadata. We keep the schema here for
* completeness and future extensibility (e.g. client notes on acceptance).
*/
export const AcceptProposalSchema = z.object({
notes: z
.string()
.max(1000, 'notes must be 1000 characters or fewer')
.optional(),
})

export type AcceptProposalInput = z.infer<typeof AcceptProposalSchema>
export type CreateProposalInput = z.infer<typeof CreateProposalSchema>
export type ProposalMilestoneInput = z.infer<typeof ProposalMilestoneSchema>
92 changes: 92 additions & 0 deletions scripts/012-proposals.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
-- Migration 012: Proposals table (UUID schema)
--
-- Stores freelancer proposals submitted against open projects.
-- A proposal can transition: pending → accepted | rejected.
-- Exactly one proposal can be accepted per project (enforced by partial unique index).
-- When a proposal is accepted the system atomically creates a contract record.

-- ─── Proposal status enum ─────────────────────────────────────────────────

DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'proposal_status') THEN
CREATE TYPE proposal_status AS ENUM ('pending', 'accepted', 'rejected', 'withdrawn');
END IF;
END;
$$;

-- ─── Proposals table ──────────────────────────────────────────────────────

CREATE TABLE IF NOT EXISTS proposals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),

-- References
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
freelancer_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE,

-- Proposal content
cover_letter TEXT NOT NULL,
proposed_amount NUMERIC(18,6) NOT NULL
CHECK (proposed_amount > 0),
currency TEXT NOT NULL DEFAULT 'USDC',
estimated_duration TEXT, -- e.g. "2 weeks", "30 days"

-- Milestone breakdown submitted with the proposal (optional JSON array)
-- Each element: { title, description, amount, due_date? }
milestone_breakdown JSONB NOT NULL DEFAULT '[]',

-- Lifecycle
status proposal_status NOT NULL DEFAULT 'pending',

-- Traceability: which contract was created from this proposal
contract_id UUID REFERENCES contracts (id) ON DELETE SET NULL,

-- Timestamps
accepted_at TIMESTAMPTZ,
rejected_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

-- A freelancer may only submit one proposal per project
CONSTRAINT uq_proposal_per_freelancer_project UNIQUE (project_id, freelancer_id)
);

-- ─── Indexes ─────────────────────────────────────────────────────────────

-- Proposals for a project (client reviews these)
CREATE INDEX IF NOT EXISTS idx_proposals_project_id
ON proposals (project_id);

-- Proposals by a freelancer (freelancer's application history)
CREATE INDEX IF NOT EXISTS idx_proposals_freelancer_id
ON proposals (freelancer_id);

-- Status filtering (e.g. open proposals awaiting response)
CREATE INDEX IF NOT EXISTS idx_proposals_status
ON proposals (status)
WHERE status = 'pending';

-- Only ONE accepted proposal is allowed per project.
-- This partial unique index enforces that at the DB level.
CREATE UNIQUE INDEX IF NOT EXISTS uq_proposals_one_accepted_per_project
ON proposals (project_id)
WHERE status = 'accepted';

-- Back-reference from contract to the originating proposal
CREATE INDEX IF NOT EXISTS idx_proposals_contract_id
ON proposals (contract_id)
WHERE contract_id IS NOT NULL;

-- ─── updated_at trigger ───────────────────────────────────────────────────

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_proposals_updated_at'
) THEN
CREATE TRIGGER trg_proposals_updated_at
BEFORE UPDATE ON proposals
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
END IF;
END;
$$;
Loading