diff --git a/app/api/proposals/[id]/accept/route.ts b/app/api/proposals/[id]/accept/route.ts new file mode 100644 index 0000000..aeed499 --- /dev/null +++ b/app/api/proposals/[id]/accept/route.ts @@ -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( + 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 }, + ) + } + }, +) diff --git a/lib/validations/index.ts b/lib/validations/index.ts index 53deec9..5d35beb 100644 --- a/lib/validations/index.ts +++ b/lib/validations/index.ts @@ -197,3 +197,65 @@ export type UpdateMilestoneInput = z.infer export type CreateProjectInput = z.infer export type UserProfileInput = z.infer export type WalletSignatureInput = z.infer + +// ─── 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 +export type CreateProposalInput = z.infer +export type ProposalMilestoneInput = z.infer diff --git a/scripts/012-proposals.sql b/scripts/012-proposals.sql new file mode 100644 index 0000000..410b43b --- /dev/null +++ b/scripts/012-proposals.sql @@ -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; +$$;