diff --git a/__tests__/api/proposals.test.ts b/__tests__/api/proposals.test.ts new file mode 100644 index 0000000..db3e13a --- /dev/null +++ b/__tests__/api/proposals.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +import { + GET as listProposals, + POST as createProposal, +} from "@/app/api/projects/[id]/proposals/route"; +import { + PATCH as updateProposal, + DELETE as deleteProposal, +} from "@/app/api/proposals/[id]/route"; +import { canTransition, isEditable } from "@/lib/proposals"; + +vi.mock("@/lib/auth/session", () => ({ + readAccessToken: vi.fn().mockReturnValue("token"), + verifyAccessToken: vi + .fn() + .mockReturnValue({ walletAddress: "GABC", jti: "jti-1" }), +})); + +vi.mock("@/lib/db", () => ({ + sql: vi.fn(), +})); + +// Always miss the cache so each test drives its own SQL responses. +vi.mock("@/lib/cache", () => ({ + cacheGet: vi.fn().mockReturnValue(undefined), + cacheSet: vi.fn(), + cacheDelete: vi.fn(), +})); + +import { sql } from "@/lib/db"; +import { readAccessToken } from "@/lib/auth/session"; + +type SqlMock = ReturnType; + +const PROJECT_ID = "11111111-1111-4111-8111-111111111111"; +const PROPOSAL_ID = "22222222-2222-4222-8222-222222222222"; +const FREELANCER_ID = "33333333-3333-4333-8333-333333333333"; +const CLIENT_ID = "44444444-4444-4444-8444-444444444444"; +const OTHER_ID = "55555555-5555-4555-8555-555555555555"; + +const freelancer = { id: FREELANCER_ID, role: "freelancer" }; +const client = { id: CLIENT_ID, role: "client" }; +const openProject = { id: PROJECT_ID, client_id: CLIENT_ID, status: "open" }; + +const validBody = { + message: "I have built several Soroban escrow dApps and can deliver this.", + budget: 1500, + deliveryTime: 14, + milestones: [ + { title: "Design", amount: 500 }, + { title: "Build", amount: 1000, dueInDays: 10 }, + ], +}; + +function proposalRow(overrides: Record = {}) { + return { + id: PROPOSAL_ID, + project_id: PROJECT_ID, + freelancer_id: FREELANCER_ID, + message: validBody.message, + budget: "1500.000000", + delivery_time: 14, + milestones: validBody.milestones, + status: "submitted", + created_at: new Date("2026-01-01T00:00:00Z"), + updated_at: new Date("2026-01-01T00:00:00Z"), + project_client_id: CLIENT_ID, + ...overrides, + }; +} + +function queueSql(responses: unknown[]) { + const mock = sql as unknown as SqlMock; + for (const response of responses) mock.mockResolvedValueOnce(response); +} + +function ctx(id: string) { + return { params: Promise.resolve({ id }) }; +} + +function jsonRequest(url: string, method: string, body?: unknown) { + return new NextRequest( + new Request(url, { + method, + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }), + ); +} + +const projectUrl = `http://localhost/api/projects/${PROJECT_ID}/proposals`; +const proposalUrl = `http://localhost/api/proposals/${PROPOSAL_ID}`; + +beforeEach(() => { + vi.clearAllMocks(); + (sql as unknown as SqlMock).mockReset(); + (readAccessToken as unknown as SqlMock).mockReturnValue("token"); +}); + +describe("status lifecycle", () => { + it("allows the defined transitions and blocks the rest", () => { + expect(canTransition("submitted", "under_review")).toBe(true); + expect(canTransition("under_review", "accepted")).toBe(true); + expect(canTransition("updated", "rejected")).toBe(true); + expect(canTransition("accepted", "rejected")).toBe(false); + expect(canTransition("rejected", "under_review")).toBe(false); + expect(canTransition("under_review", "submitted")).toBe(false); + }); + + it("only treats undecided proposals as editable", () => { + expect(isEditable("submitted")).toBe(true); + expect(isEditable("under_review")).toBe(true); + expect(isEditable("updated")).toBe(true); + expect(isEditable("accepted")).toBe(false); + expect(isEditable("rejected")).toBe(false); + }); +}); + +describe("POST /api/projects/[id]/proposals", () => { + it("returns 401 in the standard envelope without authentication", async () => { + (readAccessToken as unknown as SqlMock).mockReturnValueOnce(null); + const res = await createProposal(jsonRequest(projectUrl, "POST", validBody), ctx(PROJECT_ID)); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ + success: false, + data: null, + errors: [{ code: "AUTH_REQUIRED", message: "Authentication is required" }], + }); + expect(sql).not.toHaveBeenCalled(); + }); + + it("returns 422 with field-level messages for invalid input", async () => { + const res = await createProposal( + jsonRequest(projectUrl, "POST", { message: "short", budget: -5 }), + ctx(PROJECT_ID), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.success).toBe(false); + const fields = body.errors.map((e: { field: string }) => e.field); + expect(fields).toEqual(expect.arrayContaining(["message", "budget", "deliveryTime"])); + }); + + it("rejects milestones that exceed the budget", async () => { + const res = await createProposal( + jsonRequest(projectUrl, "POST", { ...validBody, budget: 100 }), + ctx(PROJECT_ID), + ); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.errors[0].field).toBe("milestones"); + }); + + it("returns 403 when the caller is not a freelancer", async () => { + queueSql([[client]]); + const res = await createProposal(jsonRequest(projectUrl, "POST", validBody), ctx(PROJECT_ID)); + expect(res.status).toBe(403); + }); + + it("returns 404 when the project does not exist", async () => { + queueSql([[freelancer], []]); + const res = await createProposal(jsonRequest(projectUrl, "POST", validBody), ctx(PROJECT_ID)); + expect(res.status).toBe(404); + expect((await res.json()).errors[0].code).toBe("PROJECT_NOT_FOUND"); + }); + + it("returns 409 for a duplicate active proposal", async () => { + queueSql([[freelancer], [openProject], [{ id: PROPOSAL_ID }]]); + const res = await createProposal(jsonRequest(projectUrl, "POST", validBody), ctx(PROJECT_ID)); + expect(res.status).toBe(409); + expect((await res.json()).errors[0].code).toBe("DUPLICATE_PROPOSAL"); + }); + + it("returns 409 when a concurrent insert hits the unique index", async () => { + queueSql([[freelancer], [openProject], []]); + (sql as unknown as SqlMock).mockRejectedValueOnce(Object.assign(new Error("dup"), { code: "23505" })); + const res = await createProposal(jsonRequest(projectUrl, "POST", validBody), ctx(PROJECT_ID)); + expect(res.status).toBe(409); + }); + + it("creates the proposal with status submitted", async () => { + queueSql([[freelancer], [openProject], [], [proposalRow()]]); + const res = await createProposal(jsonRequest(projectUrl, "POST", validBody), ctx(PROJECT_ID)); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.errors).toBeNull(); + expect(body.data).toMatchObject({ + id: PROPOSAL_ID, + projectId: PROJECT_ID, + freelancerId: FREELANCER_ID, + budget: 1500, + deliveryTime: 14, + status: "submitted", + }); + }); +}); + +describe("GET /api/projects/[id]/proposals", () => { + it("returns an empty list for a project with no proposals", async () => { + queueSql([[client], [openProject], []]); + const res = await listProposals(jsonRequest(projectUrl, "GET"), ctx(PROJECT_ID)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.data.proposals).toEqual([]); + expect(body.data.pagination).toMatchObject({ total: 0, hasMore: false, page: 1, pageSize: 20 }); + }); + + it("paginates results for the project client", async () => { + queueSql([[client], [openProject], [{ ...proposalRow(), total_count: "3" }]]); + const res = await listProposals( + jsonRequest(`${projectUrl}?page=1&pageSize=1&status=submitted`, "GET"), + ctx(PROJECT_ID), + ); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.data.proposals).toHaveLength(1); + expect(body.data.pagination).toMatchObject({ total: 3, totalPages: 3, hasMore: true }); + }); + + it("rejects an unknown status filter", async () => { + const res = await listProposals(jsonRequest(`${projectUrl}?status=bogus`, "GET"), ctx(PROJECT_ID)); + expect(res.status).toBe(400); + expect((await res.json()).errors[0].field).toBe("status"); + }); + + it("returns 403 for a client who does not own the project", async () => { + queueSql([[{ id: OTHER_ID, role: "client" }], [openProject]]); + const res = await listProposals(jsonRequest(projectUrl, "GET"), ctx(PROJECT_ID)); + expect(res.status).toBe(403); + }); +}); + +describe("PATCH /api/proposals/[id]", () => { + it("returns 404 for an unknown proposal", async () => { + queueSql([[freelancer], []]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { budget: 1200 }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(404); + }); + + it("returns 403 when another freelancer edits the proposal", async () => { + queueSql([[{ id: OTHER_ID, role: "freelancer" }], [proposalRow()]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { budget: 1600 }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(403); + }); + + it("blocks edits once the proposal is accepted", async () => { + queueSql([[freelancer], [proposalRow({ status: "accepted" })]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { budget: 1600 }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(409); + expect((await res.json()).errors[0].code).toBe("PROPOSAL_NOT_EDITABLE"); + }); + + it("checks milestone totals against the stored budget on partial edits", async () => { + queueSql([[freelancer], [proposalRow()]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { budget: 1000 }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(422); + }); + + it("lets the owner edit and marks the proposal updated", async () => { + queueSql([[freelancer], [proposalRow()], [proposalRow({ budget: "1600", status: "updated" })]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { budget: 1600 }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.data).toMatchObject({ budget: 1600, status: "updated" }); + }); + + it("lets the project client accept a proposal", async () => { + queueSql([[client], [proposalRow({ status: "under_review" })], [proposalRow({ status: "accepted" })]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { status: "accepted" }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(200); + expect((await res.json()).data.status).toBe("accepted"); + }); + + it("blocks invalid status transitions", async () => { + queueSql([[client], [proposalRow({ status: "rejected" })]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { status: "accepted" }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(409); + expect((await res.json()).errors[0].code).toBe("INVALID_STATUS_TRANSITION"); + }); + + it("does not let the freelancer change the status", async () => { + queueSql([[freelancer], [proposalRow()]]); + const res = await updateProposal(jsonRequest(proposalUrl, "PATCH", { status: "accepted" }), ctx(PROPOSAL_ID)); + expect(res.status).toBe(403); + }); + + it("rejects mixing status and content in one request", async () => { + const res = await updateProposal( + jsonRequest(proposalUrl, "PATCH", { status: "accepted", budget: 10 }), + ctx(PROPOSAL_ID), + ); + expect(res.status).toBe(422); + }); +}); + +describe("DELETE /api/proposals/[id]", () => { + it("returns 400 for a malformed id", async () => { + const res = await deleteProposal(jsonRequest("http://localhost/api/proposals/abc", "DELETE"), ctx("abc")); + expect(res.status).toBe(400); + }); + + it("returns 403 for anyone but the creating freelancer", async () => { + queueSql([[client], [proposalRow()]]); + const res = await deleteProposal(jsonRequest(proposalUrl, "DELETE"), ctx(PROPOSAL_ID)); + expect(res.status).toBe(403); + }); + + it("refuses to delete an accepted proposal", async () => { + queueSql([[freelancer], [proposalRow({ status: "accepted" })]]); + const res = await deleteProposal(jsonRequest(proposalUrl, "DELETE"), ctx(PROPOSAL_ID)); + expect(res.status).toBe(409); + }); + + it("deletes the owner's proposal", async () => { + queueSql([[freelancer], [proposalRow()], [{ id: PROPOSAL_ID }]]); + const res = await deleteProposal(jsonRequest(proposalUrl, "DELETE"), ctx(PROPOSAL_ID)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + success: true, + data: { id: PROPOSAL_ID, deleted: true }, + errors: null, + }); + }); +}); diff --git a/app/api/projects/[id]/proposals/route.ts b/app/api/projects/[id]/proposals/route.ts new file mode 100644 index 0000000..189d2ad --- /dev/null +++ b/app/api/projects/[id]/proposals/route.ts @@ -0,0 +1,148 @@ +// app/api/projects/[id]/proposals/route.ts +// +// Issue #216 — Proposal Management API. +// +// POST /api/projects/:id/proposals — freelancer submits a proposal +// GET /api/projects/:id/proposals — list proposals (?status=&page=&pageSize=) +// +// The dynamic segment is `[id]` (not `[projectId]`) because Next.js requires +// sibling segments to share one name and app/api/projects/[id] already exists. +// It is the project id. + +export const dynamic = "force-dynamic"; + +import { NextRequest, NextResponse } from "next/server"; +import type { AuthContext } from "@/lib/auth/middleware"; +import { + CreateProposalSchema, + ListProposalsQuerySchema, + UUIDSchema, + withProposalAuth, + fail, + failOne, + findActiveProposal, + findProject, + findUserByWallet, + insertProposal, + isUniqueViolation, + listProposals, + ok, + zodErrors, +} from "@/lib/proposals"; + +type RouteContext = { params: Promise<{ id: string }> }; + +// ─── POST ────────────────────────────────────────────────────────────────── + +export const POST = withProposalAuth( + async (req: NextRequest, auth: AuthContext, context: RouteContext): Promise => { + const { id: rawId } = await context.params; + const idResult = UUIDSchema.safeParse(rawId); + if (!idResult.success) { + return failOne(400, "INVALID_PROJECT_ID", "Project ID must be a valid UUID"); + } + const projectId = idResult.data; + + let body: unknown; + try { + body = await req.json(); + } catch { + return failOne(400, "INVALID_JSON", "Request body must be valid JSON"); + } + + // Validation errors: clear, field-level messages (422). + const parsed = CreateProposalSchema.safeParse(body); + if (!parsed.success) return fail(422, zodErrors(parsed.error)); + + try { + // Authorization: only authenticated freelancers may submit. + const user = await findUserByWallet(auth.walletAddress); + if (!user) return failOne(404, "USER_NOT_FOUND", "User not found"); + if (user.role !== "freelancer") { + return failOne(403, "FORBIDDEN", "Only freelancers can submit proposals"); + } + + const project = await findProject(projectId); + if (!project) return failOne(404, "PROJECT_NOT_FOUND", "Project not found"); + if (project.client_id === user.id) { + return failOne(403, "FORBIDDEN", "You cannot submit a proposal to your own project"); + } + if (project.status !== "open") { + return failOne(409, "PROJECT_NOT_OPEN", "This project is not accepting proposals"); + } + + // Duplicate prevention: one active proposal per freelancer per project. + const existing = await findActiveProposal(projectId, user.id); + if (existing) { + return failOne( + 409, + "DUPLICATE_PROPOSAL", + "You already have an active proposal for this project. Update it instead.", + ); + } + + const proposal = await insertProposal(projectId, user.id, parsed.data); + return ok(proposal, 201); + } catch (err) { + // Race: a concurrent insert tripped uq_project_proposals_active. + if (isUniqueViolation(err)) { + return failOne( + 409, + "DUPLICATE_PROPOSAL", + "You already have an active proposal for this project. Update it instead.", + ); + } + console.error(`[POST /api/projects/${projectId}/proposals]`, err); + return failOne(500, "PROPOSAL_CREATE_FAILED", "Failed to create proposal"); + } + }, +); + +// ─── GET ─────────────────────────────────────────────────────────────────── + +export const GET = withProposalAuth( + async (req: NextRequest, auth: AuthContext, context: RouteContext): Promise => { + const { id: rawId } = await context.params; + const idResult = UUIDSchema.safeParse(rawId); + if (!idResult.success) { + return failOne(400, "INVALID_PROJECT_ID", "Project ID must be a valid UUID"); + } + const projectId = idResult.data; + + const params = req.nextUrl.searchParams; + const query = ListProposalsQuerySchema.safeParse({ + status: params.get("status") ?? undefined, + page: params.get("page") ?? undefined, + pageSize: params.get("pageSize") ?? undefined, + }); + if (!query.success) return fail(400, zodErrors(query.error)); + + try { + const user = await findUserByWallet(auth.walletAddress); + if (!user) return failOne(404, "USER_NOT_FOUND", "User not found"); + + const project = await findProject(projectId); + if (!project) return failOne(404, "PROJECT_NOT_FOUND", "Project not found"); + + // The project's client (and admins) review every proposal; a + // freelancer only ever sees their own. Anyone else is forbidden. + const isOwnerOrAdmin = project.client_id === user.id || user.role === "admin"; + if (!isOwnerOrAdmin && user.role !== "freelancer") { + return failOne(403, "FORBIDDEN", "You do not have access to these proposals"); + } + + const page = await listProposals({ + projectId, + status: query.data.status, + freelancerId: isOwnerOrAdmin ? undefined : user.id, + page: query.data.page, + pageSize: query.data.pageSize, + }); + // Empty state: `proposals: []` with total 0 — still a 200 success. + return ok(page); + } catch (err) { + console.error(`[GET /api/projects/${projectId}/proposals]`, err); + return failOne(500, "PROPOSALS_FETCH_FAILED", "Failed to fetch proposals"); + } + }, +); diff --git a/app/api/proposals/[id]/route.ts b/app/api/proposals/[id]/route.ts index cf53422..60363e7 100644 --- a/app/api/proposals/[id]/route.ts +++ b/app/api/proposals/[id]/route.ts @@ -1,10 +1,176 @@ +// app/api/proposals/[id]/route.ts +// +// Issue #216 — Proposal Management API. +// +// PATCH /api/proposals/:id — owning freelancer edits content (status → updated), +// or the project's client moves the status +// (under_review / accepted / rejected). +// DELETE /api/proposals/:id — owning freelancer withdraws the proposal. +// +// GET /api/proposals/:id — kept unchanged from the client proposal +// dashboard (#213), which shares this path. + +export const dynamic = "force-dynamic"; + import { NextRequest, NextResponse } from "next/server"; +import type { AuthContext } from "@/lib/auth/middleware"; import { readAccessToken, verifyAccessToken } from "@/lib/auth/session"; import { db as sql } from "@/lib/proposals/db"; import { listProposalsForClient, listProposalsForFreelancer, } from "@/lib/proposals/service"; +import { + UUIDSchema, + withProposalAuth, + UpdateProposalSchema, + canTransition, + deleteProposal, + fail, + failOne, + findProposalWithProject, + findUserByWallet, + isEditable, + ok, + updateProposalContent, + updateProposalStatus, + zodErrors, +} from "@/lib/proposals"; + +type RouteContext = { params: Promise<{ id: string }> }; + +function parseProposalId(raw: string): string | null { + const result = UUIDSchema.safeParse(raw); + return result.success ? result.data : null; +} + +// ─── PATCH ───────────────────────────────────────────────────────────────── + +export const PATCH = withProposalAuth( + async (req: NextRequest, auth: AuthContext, context: RouteContext): Promise => { + const { id: rawId } = await context.params; + const proposalId = parseProposalId(rawId); + if (!proposalId) { + return failOne(400, "INVALID_PROPOSAL_ID", "Proposal ID must be a valid UUID"); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return failOne(400, "INVALID_JSON", "Request body must be valid JSON"); + } + + const parsed = UpdateProposalSchema.safeParse(body); + if (!parsed.success) return fail(422, zodErrors(parsed.error)); + const { status: nextStatus, ...content } = parsed.data; + + try { + const user = await findUserByWallet(auth.walletAddress); + if (!user) return failOne(404, "USER_NOT_FOUND", "User not found"); + + const found = await findProposalWithProject(proposalId); + if (!found) return failOne(404, "PROPOSAL_NOT_FOUND", "Proposal not found"); + const { proposal, clientId } = found; + + // ── Client review decision (status change) ── + if (nextStatus) { + if (clientId !== user.id) { + return failOne(403, "FORBIDDEN", "Only the project's client can change a proposal's status"); + } + if (!canTransition(proposal.status, nextStatus)) { + return failOne( + 409, + "INVALID_STATUS_TRANSITION", + `Cannot move a proposal from '${proposal.status}' to '${nextStatus}'`, + ); + } + const updated = await updateProposalStatus(proposal, nextStatus); + if (!updated) { + return failOne(409, "PROPOSAL_CHANGED", "The proposal was modified concurrently; reload and retry"); + } + return ok(updated); + } + + // ── Freelancer content edit ── + if (user.role !== "freelancer" || proposal.freelancerId !== user.id) { + return failOne(403, "FORBIDDEN", "Only the freelancer who created this proposal can edit it"); + } + if (!isEditable(proposal.status)) { + return failOne( + 409, + "PROPOSAL_NOT_EDITABLE", + `A proposal that is '${proposal.status}' can no longer be updated`, + ); + } + + // Milestone totals are checked against the merged budget, since either + // side of the comparison may be omitted from the PATCH body. + const budget = content.budget ?? proposal.budget; + const milestones = content.milestones ?? proposal.milestones; + const milestoneTotal = milestones.reduce((sum, m) => sum + m.amount, 0); + if (milestoneTotal > budget + 1e-9) { + return fail(422, [ + { + code: "VALIDATION_ERROR", + field: "milestones", + message: "milestone amounts cannot exceed the proposal budget", + }, + ]); + } + + const updated = await updateProposalContent(proposal, content); + if (!updated) { + return failOne(409, "PROPOSAL_NOT_EDITABLE", "This proposal can no longer be updated"); + } + return ok(updated); + } catch (err) { + console.error(`[PATCH /api/proposals/${proposalId}]`, err); + return failOne(500, "PROPOSAL_UPDATE_FAILED", "Failed to update proposal"); + } + }, +); + +// ─── DELETE ──────────────────────────────────────────────────────────────── + +export const DELETE = withProposalAuth( + async (_req: NextRequest, auth: AuthContext, context: RouteContext): Promise => { + const { id: rawId } = await context.params; + const proposalId = parseProposalId(rawId); + if (!proposalId) { + return failOne(400, "INVALID_PROPOSAL_ID", "Proposal ID must be a valid UUID"); + } + + try { + const user = await findUserByWallet(auth.walletAddress); + if (!user) return failOne(404, "USER_NOT_FOUND", "User not found"); + + const found = await findProposalWithProject(proposalId); + if (!found) return failOne(404, "PROPOSAL_NOT_FOUND", "Proposal not found"); + const { proposal } = found; + + // Restricted to the freelancer who created it. + if (user.role !== "freelancer" || proposal.freelancerId !== user.id) { + return failOne(403, "FORBIDDEN", "Only the freelancer who created this proposal can delete it"); + } + // An accepted proposal is part of an agreement and cannot be withdrawn. + if (proposal.status === "accepted") { + return failOne(409, "PROPOSAL_ACCEPTED", "An accepted proposal cannot be deleted"); + } + + const deleted = await deleteProposal(proposal); + if (!deleted) { + return failOne(409, "PROPOSAL_ACCEPTED", "An accepted proposal cannot be deleted"); + } + return ok({ id: proposal.id, deleted: true }); + } catch (err) { + console.error(`[DELETE /api/proposals/${proposalId}]`, err); + return failOne(500, "PROPOSAL_DELETE_FAILED", "Failed to delete proposal"); + } + }, +); + +// ─── GET (client proposal dashboard, #213) ───────────────────────────────── async function resolveUser(request: NextRequest) { const token = readAccessToken(request); @@ -40,4 +206,4 @@ export async function GET(request: NextRequest) { const proposals = await listProposalsForFreelancer(user.id); return NextResponse.json({ proposals }); -} \ No newline at end of file +} diff --git a/lib/db/migrations/010_project_proposals.sql b/lib/db/migrations/010_project_proposals.sql new file mode 100644 index 0000000..b8a8187 --- /dev/null +++ b/lib/db/migrations/010_project_proposals.sql @@ -0,0 +1,47 @@ +-- 010_project_proposals.sql +-- +-- Issue #216 — Proposal Management API. +-- +-- Freelancer proposals against client projects. The table is named +-- `project_proposals` so it cannot collide with the legacy `proposals` +-- table (job_id based) in scripts/001-create-tables.sql. + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'proposal_status') THEN + CREATE TYPE proposal_status AS ENUM ( + 'submitted', 'under_review', 'accepted', 'rejected', 'updated' + ); + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS project_proposals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects (id) ON DELETE CASCADE, + freelancer_id UUID NOT NULL REFERENCES users (id) ON DELETE CASCADE, + + message TEXT NOT NULL, + budget NUMERIC(18,6) NOT NULL CHECK (budget > 0), + delivery_time INTEGER NOT NULL CHECK (delivery_time > 0), -- days + milestones JSONB NOT NULL DEFAULT '[]'::jsonb, + + status proposal_status NOT NULL DEFAULT 'submitted', + + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Lookup indexes required by the issue (projectId / freelancerId). +CREATE INDEX IF NOT EXISTS idx_project_proposals_project + ON project_proposals (project_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_project_proposals_freelancer + ON project_proposals (freelancer_id); + +-- Duplicate prevention at the database level: one *active* proposal per +-- freelancer per project. A rejected proposal no longer counts as active, +-- so the freelancer may submit a fresh one. The API also pre-checks this +-- and maps a unique violation (23505) to 409 Conflict. +CREATE UNIQUE INDEX IF NOT EXISTS uq_project_proposals_active + ON project_proposals (project_id, freelancer_id) + WHERE status <> 'rejected'; diff --git a/lib/proposals.ts b/lib/proposals.ts new file mode 100644 index 0000000..ab7c531 --- /dev/null +++ b/lib/proposals.ts @@ -0,0 +1,490 @@ +// lib/proposals.ts +// +// Service layer for the Proposal Management API (issue #216). +// +// Route handlers in app/api/projects/[id]/proposals and app/api/proposals/[id] +// stay thin: validation schemas, the status state machine, the response +// envelope and all SQL live here. + +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { sql } from "@/lib/db"; +import { cacheGet, cacheSet } from "@/lib/cache"; +import { readAccessToken, verifyAccessToken } from "@/lib/auth/session"; +import type { AuthContext } from "@/lib/auth/middleware"; + +// ─── Status lifecycle ─────────────────────────────────────────────────────── + +export const PROPOSAL_STATUSES = [ + "submitted", + "under_review", + "accepted", + "rejected", + "updated", +] as const; + +export type ProposalStatus = (typeof PROPOSAL_STATUSES)[number]; + +/** + * Allowed status transitions. Anything not listed is blocked with 409. + * + * submitted / updated ──► under_review ──► accepted | rejected + * │ ▲ │ + * │ └── freelancer edit (→ updated) ◄┘ + * └──────────────────────────────────► accepted | rejected + * + * `accepted` and `rejected` are terminal. + */ +export const PROPOSAL_TRANSITIONS: Record = { + submitted: ["under_review", "updated", "accepted", "rejected"], + updated: ["under_review", "updated", "accepted", "rejected"], + under_review: ["updated", "accepted", "rejected"], + accepted: [], + rejected: [], +}; + +export function canTransition(from: ProposalStatus, to: ProposalStatus): boolean { + return PROPOSAL_TRANSITIONS[from]?.includes(to) ?? false; +} + +/** A proposal is still "under review" (editable) until it is accepted/rejected. */ +export function isEditable(status: ProposalStatus): boolean { + return status === "submitted" || status === "under_review" || status === "updated"; +} + +// ─── Response envelope ────────────────────────────────────────────────────── +// Every endpoint returns { success, data, errors } (acceptance criterion). + +export interface ApiError { + code: string; + message: string; + field?: string; +} + +export function ok(data: T, status = 200): NextResponse { + return NextResponse.json({ success: true, data, errors: null }, { status }); +} + +export function fail(status: number, errors: ApiError[]): NextResponse { + return NextResponse.json({ success: false, data: null, errors }, { status }); +} + +export function failOne(status: number, code: string, message: string): NextResponse { + return fail(status, [{ code, message }]); +} + +/** + * Same token checks as withAuthCtx in lib/auth/middleware, but the 401 uses + * the { success, data, errors } envelope so every proposal endpoint responds + * in one consistent shape. + */ +export function withProposalAuth( + handler: (req: NextRequest, auth: AuthContext, context: Ctx) => Promise, +) { + return async (req: NextRequest, context: Ctx): Promise => { + const token = readAccessToken(req); + const payload = token ? verifyAccessToken(token) : null; + if (!payload) { + return failOne(401, "AUTH_REQUIRED", "Authentication is required"); + } + return handler(req, { walletAddress: payload.walletAddress, tokenJti: payload.jti }, context); + }; +} + +/** Flattens a ZodError into field-level error entries with clear messages. */ +export function zodErrors(error: z.ZodError): ApiError[] { + return error.issues.map((issue) => ({ + code: "VALIDATION_ERROR", + message: issue.message, + field: issue.path.length ? issue.path.join(".") : undefined, + })); +} + +// ─── Validation schemas (Zod) ─────────────────────────────────────────────── + +export const UUIDSchema = z.string().uuid(); + +const MilestoneSchema = z.object({ + title: z + .string({ required_error: "milestone title is required" }) + .trim() + .min(1, "milestone title cannot be empty") + .max(200, "milestone title must be 200 characters or fewer"), + description: z + .string() + .max(1000, "milestone description must be 1000 characters or fewer") + .optional(), + amount: z + .number({ invalid_type_error: "milestone amount must be a number" }) + .positive("milestone amount must be a positive number"), + dueInDays: z + .number() + .int("milestone dueInDays must be an integer") + .positive("milestone dueInDays must be a positive integer") + .optional(), +}); + +const messageField = z + .string({ required_error: "message is required", invalid_type_error: "message must be a string" }) + .trim() + .min(20, "message must be at least 20 characters") + .max(5000, "message must be 5000 characters or fewer"); + +const budgetField = z + .number({ required_error: "budget is required", invalid_type_error: "budget must be a number" }) + .positive("budget must be a positive number") + .max(1_000_000_000, "budget is too large"); + +const deliveryTimeField = z + .number({ + required_error: "deliveryTime is required", + invalid_type_error: "deliveryTime must be a number of days", + }) + .int("deliveryTime must be a whole number of days") + .min(1, "deliveryTime must be at least 1 day") + .max(3650, "deliveryTime must be 3650 days or fewer"); + +const milestonesField = z + .array(MilestoneSchema) + .max(20, "a proposal can have at most 20 milestones"); + +/** Milestone amounts must not add up to more than the proposed budget. */ +function milestonesWithinBudget(budget?: number, milestones?: { amount: number }[]): boolean { + if (budget === undefined || !milestones?.length) return true; + const total = milestones.reduce((sum, m) => sum + m.amount, 0); + // Small epsilon guards against floating point noise on decimal amounts. + return total <= budget + 1e-9; +} + +export const CreateProposalSchema = z + .object({ + message: messageField, + budget: budgetField, + deliveryTime: deliveryTimeField, + milestones: milestonesField.optional(), + }) + .strict() + .refine((d) => milestonesWithinBudget(d.budget, d.milestones), { + message: "milestone amounts cannot exceed the proposal budget", + path: ["milestones"], + }); + +export type CreateProposalInput = z.infer; + +/** + * PATCH body. Content fields are for the owning freelancer; `status` is for + * the project's client (review decisions). Mixing both is rejected so each + * request has exactly one actor. + */ +export const UpdateProposalSchema = z + .object({ + message: messageField.optional(), + budget: budgetField.optional(), + deliveryTime: deliveryTimeField.optional(), + milestones: milestonesField.optional(), + status: z + .enum(["under_review", "accepted", "rejected"], { + errorMap: () => ({ message: "status must be one of: under_review, accepted, rejected" }), + }) + .optional(), + }) + .strict() + .refine((d) => Object.keys(d).length > 0, { + message: "At least one field must be provided for an update", + }) + .refine( + (d) => + d.status === undefined || + (d.message === undefined && + d.budget === undefined && + d.deliveryTime === undefined && + d.milestones === undefined), + { message: "status cannot be changed in the same request as proposal content", path: ["status"] }, + ); + +export type UpdateProposalInput = z.infer; + +export const ListProposalsQuerySchema = z.object({ + status: z + .enum(PROPOSAL_STATUSES, { + errorMap: () => ({ message: `status must be one of: ${PROPOSAL_STATUSES.join(", ")}` }), + }) + .optional(), + page: z.coerce.number().int("page must be an integer").min(1, "page must be at least 1").default(1), + pageSize: z.coerce + .number() + .int("pageSize must be an integer") + .min(1, "pageSize must be at least 1") + .max(100, "pageSize must be 100 or fewer") + .default(20), +}); + +// ─── Domain types & row mapping ───────────────────────────────────────────── + +export interface ProposalMilestone { + title: string; + description?: string; + amount: number; + dueInDays?: number; +} + +export interface Proposal { + id: string; + projectId: string; + freelancerId: string; + message: string; + budget: number; + deliveryTime: number; + milestones: ProposalMilestone[]; + status: ProposalStatus; + createdAt: string; + updatedAt: string; +} + +function toIso(value: unknown): string { + return value instanceof Date ? value.toISOString() : String(value); +} + +export function rowToProposal(row: Record): Proposal { + const rawMilestones = row.milestones; + const milestones = + typeof rawMilestones === "string" + ? (JSON.parse(rawMilestones) as ProposalMilestone[]) + : ((rawMilestones as ProposalMilestone[] | null) ?? []); + return { + id: String(row.id), + projectId: String(row.project_id), + freelancerId: String(row.freelancer_id), + message: row.message as string, + budget: Number(row.budget), + deliveryTime: Number(row.delivery_time), + milestones, + status: row.status as ProposalStatus, + createdAt: toIso(row.created_at), + updatedAt: toIso(row.updated_at), + }; +} + +// ─── Lookups ──────────────────────────────────────────────────────────────── + +export interface UserRow { + id: string; + role: string; +} + +export interface ProjectRow { + id: string; + client_id: string; + status: string; +} + +export async function findUserByWallet(walletAddress: string): Promise { + const rows = (await sql` + SELECT id, role FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + `) as Array>; + if (!rows.length) return null; + return { id: String(rows[0].id), role: String(rows[0].role) }; +} + +export async function findProject(projectId: string): Promise { + const rows = (await sql` + SELECT id, client_id, status FROM projects WHERE id = ${projectId} LIMIT 1 + `) as Array>; + if (!rows.length) return null; + return { + id: String(rows[0].id), + client_id: String(rows[0].client_id), + status: String(rows[0].status), + }; +} + +export async function findProposalWithProject( + proposalId: string, +): Promise<{ proposal: Proposal; clientId: string } | null> { + const rows = (await sql` + SELECT pp.*, p.client_id AS project_client_id + FROM project_proposals pp + JOIN projects p ON p.id = pp.project_id + WHERE pp.id = ${proposalId} + LIMIT 1 + `) as Array>; + if (!rows.length) return null; + return { proposal: rowToProposal(rows[0]), clientId: String(rows[0].project_client_id) }; +} + +/** Returns the freelancer's active (non-rejected) proposal on the project, if any. */ +export async function findActiveProposal( + projectId: string, + freelancerId: string, +): Promise<{ id: string } | null> { + const rows = (await sql` + SELECT id FROM project_proposals + WHERE project_id = ${projectId} + AND freelancer_id = ${freelancerId} + AND status <> 'rejected' + LIMIT 1 + `) as Array>; + return rows.length ? { id: String(rows[0].id) } : null; +} + +// ─── Caching ──────────────────────────────────────────────────────────────── +// List results are cached briefly per project. Each write bumps the +// project's version so stale pages are never served after a change within +// the same instance. + +const LIST_CACHE_TTL_MS = 30_000; +const projectCacheVersion = new Map(); + +function listCacheKey(projectId: string, parts: Array): string { + const version = projectCacheVersion.get(projectId) ?? 0; + return `proposals:${projectId}:v${version}:${parts.map((p) => p ?? "*").join(":")}`; +} + +export function invalidateProjectProposals(projectId: string): void { + projectCacheVersion.set(projectId, (projectCacheVersion.get(projectId) ?? 0) + 1); +} + +// ─── Mutations & queries ──────────────────────────────────────────────────── + +/** Postgres unique_violation — raised by uq_project_proposals_active. */ +export function isUniqueViolation(err: unknown): boolean { + return typeof err === "object" && err !== null && (err as { code?: string }).code === "23505"; +} + +export async function insertProposal( + projectId: string, + freelancerId: string, + input: CreateProposalInput, +): Promise { + const rows = (await sql` + INSERT INTO project_proposals ( + project_id, freelancer_id, message, budget, delivery_time, milestones, status + ) VALUES ( + ${projectId}, + ${freelancerId}, + ${input.message}, + ${input.budget}, + ${input.deliveryTime}, + ${JSON.stringify(input.milestones ?? [])}::jsonb, + 'submitted' + ) + RETURNING * + `) as Array>; + invalidateProjectProposals(projectId); + return rowToProposal(rows[0]); +} + +export interface ListProposalsOptions { + projectId: string; + status?: ProposalStatus; + /** When set, only this freelancer's proposals are returned. */ + freelancerId?: string; + page: number; + pageSize: number; +} + +export interface ProposalPage { + proposals: Proposal[]; + pagination: { + page: number; + pageSize: number; + total: number; + totalPages: number; + hasMore: boolean; + }; +} + +export async function listProposals(opts: ListProposalsOptions): Promise { + const { projectId, status, freelancerId, page, pageSize } = opts; + const key = listCacheKey(projectId, [status, freelancerId, page, pageSize]); + const cached = cacheGet(key); + if (cached) return cached; + + const offset = (page - 1) * pageSize; + const statusFilter = status ?? null; + const freelancerFilter = freelancerId ?? null; + + const rows = (await sql` + SELECT pp.*, COUNT(*) OVER() AS total_count + FROM project_proposals pp + WHERE pp.project_id = ${projectId} + AND (${statusFilter}::proposal_status IS NULL OR pp.status = ${statusFilter}::proposal_status) + AND (${freelancerFilter}::uuid IS NULL OR pp.freelancer_id = ${freelancerFilter}::uuid) + ORDER BY pp.created_at DESC + LIMIT ${pageSize} OFFSET ${offset} + `) as Array>; + + // Empty state: a project with no proposals returns an empty list, not an error. + const total = rows.length ? Number(rows[0].total_count) || 0 : 0; + const result: ProposalPage = { + proposals: rows.map(rowToProposal), + pagination: { + page, + pageSize, + total, + totalPages: Math.ceil(total / pageSize), + hasMore: page * pageSize < total, + }, + }; + cacheSet(key, result, LIST_CACHE_TTL_MS); + return result; +} + +/** + * Applies a freelancer content edit. The status moves to `updated`, and the + * WHERE clause re-checks editability so a concurrent accept/reject wins. + */ +export async function updateProposalContent( + current: Proposal, + input: Omit, +): Promise { + const message = input.message ?? current.message; + const budget = input.budget ?? current.budget; + const deliveryTime = input.deliveryTime ?? current.deliveryTime; + const milestones = input.milestones ?? current.milestones; + + const rows = (await sql` + UPDATE project_proposals + SET message = ${message}, + budget = ${budget}, + delivery_time = ${deliveryTime}, + milestones = ${JSON.stringify(milestones)}::jsonb, + status = 'updated', + updated_at = NOW() + WHERE id = ${current.id} + AND status IN ('submitted', 'under_review', 'updated') + RETURNING * + `) as Array>; + if (!rows.length) return null; + invalidateProjectProposals(current.projectId); + return rowToProposal(rows[0]); +} + +/** Moves a proposal to `next`, guarded on the status it was read with. */ +export async function updateProposalStatus( + current: Proposal, + next: ProposalStatus, +): Promise { + const rows = (await sql` + UPDATE project_proposals + SET status = ${next}::proposal_status, + updated_at = NOW() + WHERE id = ${current.id} + AND status = ${current.status}::proposal_status + RETURNING * + `) as Array>; + if (!rows.length) return null; + invalidateProjectProposals(current.projectId); + return rowToProposal(rows[0]); +} + +export async function deleteProposal(proposal: Proposal): Promise { + const rows = (await sql` + DELETE FROM project_proposals + WHERE id = ${proposal.id} + AND status <> 'accepted' + RETURNING id + `) as Array>; + if (rows.length) invalidateProjectProposals(proposal.projectId); + return rows.length > 0; +}