From b14ec04d1cf3abf71543006b9c5aafcfbae12fc6 Mon Sep 17 00:00:00 2001 From: CodeNinjaStephen Date: Fri, 16 Oct 2026 07:52:51 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20atomic=20proposal=20acceptance=20?= =?UTF-8?q?=E2=86=92=20contract=20creation=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add scripts/012-proposals.sql: proposals table with UUID PKs, proposal_status enum, JSONB milestone_breakdown, and a partial unique index (WHERE status='accepted') enforcing one accepted proposal per project at the DB level - Add lib/proposals.ts: service layer with acceptProposal() running a 10-step atomic BEGIN/COMMIT/ROLLBACK transaction: validate proposal, verify client ownership, check project state, mark accepted, reject other proposals, create contract (status=pending, escrow_status=unfunded), back-reference proposal.contract_id, create milestone rows, advance project to in_progress, insert audit log - Add app/api/proposals/[id]/accept/route.ts: POST handler with withAuthCtx, UUID path validation, client/admin-only role guard, wallet→userId resolution, deterministic HTTP codes for all domain errors (400/401/403/404/409/422/500) - Add lib/validations/index.ts: AcceptProposalSchema, CreateProposalSchema, ProposalMilestoneSchema - Add __tests__/api/proposals.test.ts: 29 tests covering atomicity rollback, duplicate prevention, unauthorized access, project state validation, milestone creation, escrow init, concurrent race (unique constraint), and full success shape --- __tests__/api/proposals.test.ts | 699 +++++++++++++++++++++++++ app/api/proposals/[id]/accept/route.ts | 195 +++++++ lib/proposals.ts | 430 +++++++++++++++ lib/validations/index.ts | 62 +++ scripts/012-proposals.sql | 92 ++++ 5 files changed, 1478 insertions(+) create mode 100644 __tests__/api/proposals.test.ts create mode 100644 app/api/proposals/[id]/accept/route.ts create mode 100644 lib/proposals.ts create mode 100644 scripts/012-proposals.sql diff --git a/__tests__/api/proposals.test.ts b/__tests__/api/proposals.test.ts new file mode 100644 index 0000000..92db85c --- /dev/null +++ b/__tests__/api/proposals.test.ts @@ -0,0 +1,699 @@ +// __tests__/api/proposals.test.ts +// +// Unit tests for: +// - POST /api/proposals/[id]/accept (route handler) +// - lib/proposals.ts (service layer) +// +// Strategy: +// • lib/db sql → vi.mock, queued with mockResolvedValueOnce (route layer) +// • @neondatabase/serverless neon() → vi.mock returning a shared dbMock +// (lib/proposals.ts uses neon() directly for transaction support) +// • Auth middleware → vi.mock with injectAuth helper +// • DATABASE_URL → set to a dummy value so getTxDb() doesn't throw +// before neon() is called + +// Must be set before lib/proposals.ts is imported +process.env.DATABASE_URL = 'postgres://test:test@localhost/test' + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { NextRequest } from 'next/server' + +// ─── Shared constants ───────────────────────────────────────────────────── + +const TEST_WALLET = 'GCLIENTWALLET00000000000000000000000000000000000000000000' +const TEST_CLIENT_ID = '00000000-0000-0000-0000-aaaaaaaaaaaa' +const TEST_FREELANCER_ID = '00000000-0000-0000-0000-bbbbbbbbbbbb' +const TEST_PROJECT_ID = '00000000-0000-0000-0000-cccccccccccc' +const TEST_PROPOSAL_ID = '00000000-0000-0000-0000-dddddddddddd' +const TEST_CONTRACT_ID = '00000000-0000-0000-0000-eeeeeeeeeeee' + +// ─── Mock: auth middleware ──────────────────────────────────────────────── + +vi.mock('@/lib/auth/middleware', async () => { + const actual = await vi.importActual( + '@/lib/auth/middleware', + ) + const injectAuth = + (handler: (...args: unknown[]) => unknown) => + async (request: unknown, ...rest: unknown[]) => + handler( + request, + { walletAddress: TEST_WALLET, tokenJti: 'test-jti' }, + ...rest, + ) + return { ...actual, withAuth: injectAuth, withAuthCtx: injectAuth } +}) + +// ─── Mock: lib/db (sql tagged-template used in the route handler) ───────── + +vi.mock('@/lib/db', () => ({ sql: vi.fn() })) + +// ─── Mock: @neondatabase/serverless ────────────────────────────────────── +// +// lib/proposals.ts does: const db = neon(url); await db('BEGIN', []) +// We mock neon() to always return the same dbMock so we can queue +// return values on it. + +const dbMock = vi.fn() +vi.mock('@neondatabase/serverless', () => ({ + neon: vi.fn(() => dbMock), +})) + +// ─── Imports (after mocks are hoisted) ─────────────────────────────────── + +import { sql } from '@/lib/db' +import { POST } from '@/app/api/proposals/[id]/accept/route' +import { + acceptProposal, + getProposalById, + ProposalAlreadyAcceptedError, + ProposalNotFoundError, + ProposalNotPendingError, + ProjectNotActiveError, + UnauthorizedAcceptanceError, +} from '@/lib/proposals' + +type SqlMock = ReturnType + +/** Queue sequential return values on the route-level sql mock */ +function queueSql(responses: unknown[]) { + const mock = sql as unknown as SqlMock + for (const r of responses) mock.mockResolvedValueOnce(r) +} + +/** Queue a rejection on the direct-neon dbMock */ +function queueDbReject(err: unknown) { + dbMock.mockRejectedValueOnce(err) +} + +/** Queue sequential return values on the direct-neon dbMock. + * If an entry is an Error instance it is queued as a rejection so the + * service code catches it correctly. */ +function queueDb(responses: unknown[]) { + for (const r of responses) { + if (r instanceof Error) { + dbMock.mockRejectedValueOnce(r) + } else { + dbMock.mockResolvedValueOnce(r) + } + } +} + +// ─── Request factory ────────────────────────────────────────────────────── + +function makePostRequest(proposalId: string, body?: Record): NextRequest { + const url = `http://localhost/api/proposals/${proposalId}/accept` + return new NextRequest( + new Request(url, { + method: 'POST', + headers: body !== undefined ? { 'Content-Type': 'application/json' } : {}, + body: body !== undefined ? JSON.stringify(body) : undefined, + }), + ) +} + +function routeCtx(proposalId: string) { + return { params: Promise.resolve({ id: proposalId }) } +} + +// ─── Data factories ─────────────────────────────────────────────────────── + +function proposalRow(overrides: Record = {}): Record { + return { + id: TEST_PROPOSAL_ID, + project_id: TEST_PROJECT_ID, + freelancer_id: TEST_FREELANCER_ID, + cover_letter: 'I am a great fit', + proposed_amount: '500.000000', + currency: 'USDC', + estimated_duration: '2 weeks', + milestone_breakdown: [], + status: 'pending', + contract_id: null, + accepted_at: null, + rejected_at: null, + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + ...overrides, + } +} + +function projectRow(overrides: Record = {}): Record { + return { id: TEST_PROJECT_ID, client_id: TEST_CLIENT_ID, status: 'open', ...overrides } +} + +function contractRow(overrides: Record = {}): Record { + return { + id: TEST_CONTRACT_ID, + project_id: TEST_PROJECT_ID, + client_id: TEST_CLIENT_ID, + freelancer_id: TEST_FREELANCER_ID, + total_amount: '500.000000', + currency: 'USDC', + status: 'pending', + escrow_status: 'unfunded', + created_at: new Date('2026-01-01'), + updated_at: new Date('2026-01-01'), + ...overrides, + } +} + +// ─── Setup ──────────────────────────────────────────────────────────────── + +beforeEach(async () => { + // Reset all mocks fully (clears call history and queued return values) + vi.resetAllMocks() + // Re-establish the neon() → dbMock binding (resetAllMocks wipes it) + const mod = await vi.importMock( + '@neondatabase/serverless', + ) + vi.mocked(mod.neon).mockReturnValue(dbMock as ReturnType) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Route handler tests — POST /api/proposals/[id]/accept +// ═══════════════════════════════════════════════════════════════════════════ + +describe('POST /api/proposals/[id]/accept — route handler', () => { + // ── Parameter validation ──────────────────────────────────────────────── + + it('returns 400 when proposal ID is not a valid UUID', async () => { + const req = makePostRequest('not-a-uuid') + const res = await POST(req, routeCtx('not-a-uuid')) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('INVALID_UUID') + }) + + it('returns 400 when request body is not valid JSON', async () => { + const url = `http://localhost/api/proposals/${TEST_PROPOSAL_ID}/accept` + const req = new NextRequest( + new Request(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid-json}', + }), + ) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('INVALID_JSON') + }) + + it('returns 422 when body fails schema validation', async () => { + const req = makePostRequest(TEST_PROPOSAL_ID, { notes: 'x'.repeat(1001) }) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(422) + const body = await res.json() + expect(body.code).toBe('INVALID_REQUEST_BODY') + expect(body.details).toHaveProperty('notes') + }) + + // ── Auth & role guards ────────────────────────────────────────────────── + + it('returns 401 when authenticated wallet has no user record', async () => { + queueSql([[]]) // SELECT id, role FROM users → empty + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('USER_NOT_FOUND') + }) + + it('returns 403 when caller has role=freelancer', async () => { + queueSql([[{ id: TEST_FREELANCER_ID, role: 'freelancer' }]]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('FORBIDDEN') + }) + + // ── Happy path ────────────────────────────────────────────────────────── + + it('returns 201 with proposal, contract, and milestones on success', async () => { + // Route-level: resolve wallet + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + + // Service-level transaction (10 steps for empty milestone breakdown) + queueDb([ + [], // BEGIN + [proposalRow()], // SELECT proposal FOR UPDATE + [projectRow()], // SELECT project FOR UPDATE + [proposalRow({ status: 'accepted', accepted_at: new Date() })], // UPDATE proposal + [], // UPDATE reject other proposals + [contractRow()], // INSERT contract + [], // UPDATE proposal.contract_id + // (no milestone iterations — empty array) + [], // UPDATE project status + [], // INSERT audit log + [], // COMMIT + ]) + + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(201) + const body = await res.json() + expect(body.message).toMatch(/accepted/i) + expect(body.proposal.id).toBe(TEST_PROPOSAL_ID) + expect(body.contract.id).toBe(TEST_CONTRACT_ID) + expect(body.contract.escrowStatus).toBe('unfunded') + expect(body.milestones).toBeInstanceOf(Array) + expect(body.milestones).toHaveLength(0) + }) + + it('creates milestones from proposal milestone_breakdown', async () => { + const milestones = [ + { title: 'Design', amount: 200, description: 'UI mockups' }, + { title: 'Dev', amount: 300, description: 'Implementation' }, + ] + + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + + queueDb([ + [], // BEGIN + [proposalRow({ milestone_breakdown: milestones })], // SELECT proposal + [projectRow()], // SELECT project + [proposalRow({ status: 'accepted', accepted_at: new Date(), milestone_breakdown: milestones })], // UPDATE proposal + [], // reject others + [contractRow()], // INSERT contract + [], // UPDATE proposal.contract_id + [{ // INSERT milestone[0] + id: '00000000-0000-0000-0001-000000000001', + contract_id: TEST_CONTRACT_ID, + project_id: TEST_PROJECT_ID, + title: 'Design', + description: 'UI mockups', + amount: '200.000000', + currency: 'USDC', + due_date: null, + sort_order: 0, + status: 'pending', + created_at: new Date('2026-01-01'), + }], + [{ // INSERT milestone[1] + id: '00000000-0000-0000-0001-000000000002', + contract_id: TEST_CONTRACT_ID, + project_id: TEST_PROJECT_ID, + title: 'Dev', + description: 'Implementation', + amount: '300.000000', + currency: 'USDC', + due_date: null, + sort_order: 1, + status: 'pending', + created_at: new Date('2026-01-01'), + }], + [], // UPDATE project + [], // INSERT audit log + [], // COMMIT + ]) + + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(201) + const body = await res.json() + expect(body.milestones).toHaveLength(2) + expect(body.milestones[0].title).toBe('Design') + expect(body.milestones[0].amount).toBe(200) + expect(body.milestones[1].title).toBe('Dev') + expect(body.milestones[1].sortOrder).toBe(1) + }) + + // ── Domain error propagation ──────────────────────────────────────────── + + it('returns 404 when proposal does not exist', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], // BEGIN + [], // SELECT proposal → not found (empty array) + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.code).toBe('PROPOSAL_NOT_FOUND') + }) + + it('returns 409 when proposal is already accepted', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], + [proposalRow({ status: 'accepted' })], + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('PROPOSAL_ALREADY_ACCEPTED') + }) + + it('returns 409 when proposal is rejected (not pending)', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], + [proposalRow({ status: 'rejected' })], + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('PROPOSAL_NOT_PENDING') + }) + + it('returns 409 when project is in a non-acceptable state (completed)', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], + [proposalRow()], + [projectRow({ status: 'completed' })], + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('PROJECT_NOT_ACTIVE') + }) + + it('returns 403 when caller is not the project client', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], + [proposalRow()], + [projectRow({ client_id: 'different-client-id' })], + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(403) + const body = await res.json() + expect(body.code).toBe('FORBIDDEN') + }) + + it('returns 409 on DB unique constraint violation (concurrent acceptance race)', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], + [proposalRow()], + [projectRow()], + [proposalRow({ status: 'accepted', accepted_at: new Date() })], + [], + // DB raises unique violation on uq_proposals_one_accepted_per_project + Object.assign( + new Error('duplicate key value violates unique constraint "uq_proposals_one_accepted_per_project"'), + {}, + ), + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(409) + const body = await res.json() + expect(body.code).toBe('PROJECT_ALREADY_HAS_ACCEPTED_PROPOSAL') + }) + + it('returns 500 on unexpected server error', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'client' }]]) + queueDb([ + [], + [proposalRow()], + [projectRow()], + [proposalRow({ status: 'accepted', accepted_at: new Date() })], + [], + Object.assign(new Error('connection reset'), {}), + [], // ROLLBACK + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(500) + const body = await res.json() + expect(body.code).toBe('SERVER_ERROR') + }) + + // ── Admin access ──────────────────────────────────────────────────────── + + it('allows admin role to accept a proposal', async () => { + queueSql([[{ id: TEST_CLIENT_ID, role: 'admin' }]]) + queueDb([ + [], + [proposalRow()], + [projectRow()], + [proposalRow({ status: 'accepted', accepted_at: new Date() })], + [], + [contractRow()], + [], + [], + [], + [], + ]) + const req = makePostRequest(TEST_PROPOSAL_ID) + const res = await POST(req, routeCtx(TEST_PROPOSAL_ID)) + expect(res.status).toBe(201) + }) +}) + +// ═══════════════════════════════════════════════════════════════════════════ +// Service layer tests — lib/proposals.ts +// ═══════════════════════════════════════════════════════════════════════════ + +describe('getProposalById', () => { + it('returns null when no proposal exists', async () => { + dbMock.mockResolvedValueOnce([]) // SELECT * FROM proposals → empty + const result = await getProposalById(TEST_PROPOSAL_ID) + expect(result).toBeNull() + }) + + it('maps a DB row to a Proposal object', async () => { + dbMock.mockResolvedValueOnce([proposalRow()]) + const result = await getProposalById(TEST_PROPOSAL_ID) + expect(result).not.toBeNull() + expect(result!.id).toBe(TEST_PROPOSAL_ID) + expect(result!.projectId).toBe(TEST_PROJECT_ID) + expect(result!.freelancerId).toBe(TEST_FREELANCER_ID) + expect(result!.status).toBe('pending') + expect(result!.proposedAmount).toBe(500) + expect(result!.milestoneBreakdown).toEqual([]) + }) +}) + +describe('acceptProposal', () => { + it('throws ProposalNotFoundError when proposal does not exist', async () => { + queueDb([ + [], // BEGIN + [], // SELECT proposal → empty + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + ProposalNotFoundError, + ) + }) + + it('throws ProposalAlreadyAcceptedError for already-accepted proposals', async () => { + queueDb([ + [], + [proposalRow({ status: 'accepted' })], + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + ProposalAlreadyAcceptedError, + ) + }) + + it('throws ProposalNotPendingError for rejected proposals', async () => { + queueDb([ + [], + [proposalRow({ status: 'rejected' })], + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + ProposalNotPendingError, + ) + }) + + it('throws ProposalNotPendingError for withdrawn proposals', async () => { + queueDb([ + [], + [proposalRow({ status: 'withdrawn' })], + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + ProposalNotPendingError, + ) + }) + + it('throws UnauthorizedAcceptanceError when caller is not the project client', async () => { + queueDb([ + [], + [proposalRow()], + [projectRow({ client_id: 'someone-else-uuid' })], + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + UnauthorizedAcceptanceError, + ) + }) + + it('throws ProjectNotActiveError for a cancelled project', async () => { + queueDb([ + [], + [proposalRow()], + [projectRow({ client_id: TEST_CLIENT_ID, status: 'cancelled' })], + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + ProjectNotActiveError, + ) + }) + + it('throws ProjectNotActiveError for a completed project', async () => { + queueDb([ + [], + [proposalRow()], + [projectRow({ client_id: TEST_CLIENT_ID, status: 'completed' })], + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow( + ProjectNotActiveError, + ) + }) + + it('rolls back and rethrows on unexpected DB error during contract insert', async () => { + const boom = new Error('DB down') + queueDb([ + [], // BEGIN + [proposalRow()], // SELECT proposal + [projectRow()], // SELECT project + [proposalRow({ status: 'accepted', accepted_at: new Date() })], + [], // UPDATE other proposals + boom, // INSERT contract → error + [], // ROLLBACK + ]) + await expect(acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID)).rejects.toThrow('DB down') + }) + + it('returns correct AcceptProposalResult shape on success (no milestones)', async () => { + queueDb([ + [], // BEGIN + [proposalRow()], // SELECT proposal + [projectRow()], // SELECT project + [proposalRow({ status: 'accepted', accepted_at: new Date() })], // UPDATE proposal + [], // UPDATE other proposals (reject) + [contractRow()], // INSERT contract + [], // UPDATE proposal.contract_id + // no milestone iterations — empty array + [], // UPDATE project status + [], // INSERT audit log + [], // COMMIT + ]) + + const result = await acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID) + + expect(result.proposal.id).toBe(TEST_PROPOSAL_ID) + expect(result.proposal.status).toBe('accepted') + expect(result.proposal.contractId).toBe(TEST_CONTRACT_ID) + + expect(result.contract.id).toBe(TEST_CONTRACT_ID) + expect(result.contract.status).toBe('pending') + expect(result.contract.escrowStatus).toBe('unfunded') + expect(result.contract.proposalId).toBe(TEST_PROPOSAL_ID) + expect(result.contract.totalAmount).toBe(500) + + expect(result.milestones).toHaveLength(0) + }) + + it('creates milestone rows matching the breakdown and returns them', async () => { + const breakdown = [ + { title: 'Phase 1', amount: 200 }, + { title: 'Phase 2', amount: 300 }, + ] + + queueDb([ + [], // BEGIN + [proposalRow({ milestone_breakdown: breakdown })], // SELECT proposal + [projectRow()], // SELECT project + [proposalRow({ status: 'accepted', accepted_at: new Date(), milestone_breakdown: breakdown })], + [], // reject others + [contractRow()], // INSERT contract + [], // UPDATE proposal.contract_id + [{ // INSERT milestone[0] + id: '00000000-0000-0000-0001-000000000001', + contract_id: TEST_CONTRACT_ID, + project_id: TEST_PROJECT_ID, + title: 'Phase 1', + description: null, + amount: '200.000000', + currency: 'USDC', + due_date: null, + sort_order: 0, + status: 'pending', + created_at: new Date(), + }], + [{ // INSERT milestone[1] + id: '00000000-0000-0000-0001-000000000002', + contract_id: TEST_CONTRACT_ID, + project_id: TEST_PROJECT_ID, + title: 'Phase 2', + description: null, + amount: '300.000000', + currency: 'USDC', + due_date: null, + sort_order: 1, + status: 'pending', + created_at: new Date(), + }], + [], // UPDATE project + [], // INSERT audit log + [], // COMMIT + ]) + + const result = await acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID) + + expect(result.milestones).toHaveLength(2) + expect(result.milestones[0].title).toBe('Phase 1') + expect(result.milestones[0].amount).toBe(200) + expect(result.milestones[0].sortOrder).toBe(0) + expect(result.milestones[0].status).toBe('pending') + expect(result.milestones[1].title).toBe('Phase 2') + expect(result.milestones[1].sortOrder).toBe(1) + }) + + it('initializes escrow status to unfunded on contract creation', async () => { + queueDb([ + [], + [proposalRow()], + [projectRow()], + [proposalRow({ status: 'accepted', accepted_at: new Date() })], + [], + [contractRow({ escrow_status: 'unfunded' })], + [], + [], + [], + [], + ]) + + const result = await acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID) + expect(result.contract.escrowStatus).toBe('unfunded') + }) + + it('sets contract status to pending until escrow is funded', async () => { + queueDb([ + [], + [proposalRow()], + [projectRow()], + [proposalRow({ status: 'accepted', accepted_at: new Date() })], + [], + [contractRow({ status: 'pending' })], + [], + [], + [], + [], + ]) + + const result = await acceptProposal(TEST_PROPOSAL_ID, TEST_CLIENT_ID) + expect(result.contract.status).toBe('pending') + }) +}) 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/proposals.ts b/lib/proposals.ts new file mode 100644 index 0000000..7518793 --- /dev/null +++ b/lib/proposals.ts @@ -0,0 +1,430 @@ +// lib/proposals.ts +// +// Service layer for proposal operations and the atomic +// proposal-acceptance → contract-creation flow. +// +// All DB access goes through this module so route handlers stay thin and +// the logic is independently unit-testable. The acceptance flow runs +// inside a single BEGIN/COMMIT block to guarantee atomicity: if any step +// fails, the entire transaction rolls back and no partial state is left. +// +// Column mapping: DB snake_case ←→ JS camelCase (done manually — no ORM). + +import { neon } from '@neondatabase/serverless' + +// ─── Types ──────────────────────────────────────────────────────────────── + +export type ProposalStatus = 'pending' | 'accepted' | 'rejected' | 'withdrawn' + +/** A single milestone entry inside proposal.milestoneBreakdown */ +export interface ProposalMilestone { + title: string + description?: string + amount: number + due_date?: string // ISO-8601 date string, optional +} + +export interface Proposal { + id: string + projectId: string + freelancerId: string + coverLetter: string + proposedAmount: number + currency: string + estimatedDuration: string | null + milestoneBreakdown: ProposalMilestone[] + status: ProposalStatus + contractId: string | null + acceptedAt: string | null + rejectedAt: string | null + createdAt: string + updatedAt: string +} + +export interface AcceptProposalResult { + proposal: Proposal + contract: { + id: string + projectId: string + clientId: string + freelancerId: string + status: string + escrowStatus: string + totalAmount: number + currency: string + proposalId: string + createdAt: string + updatedAt: string + } + milestones: Array<{ + id: string + contractId: string + projectId: string + title: string + description: string | null + amount: number + currency: string + dueDate: string | null + sortOrder: number + status: string + createdAt: string + }> +} + +// ─── Errors ─────────────────────────────────────────────────────────────── + +export class ProposalNotFoundError extends Error { + constructor(proposalId: string) { + super(`Proposal ${proposalId} not found`) + this.name = 'ProposalNotFoundError' + } +} + +export class ProposalAlreadyAcceptedError extends Error { + constructor(proposalId: string) { + super(`Proposal ${proposalId} has already been accepted`) + this.name = 'ProposalAlreadyAcceptedError' + } +} + +export class ProposalNotPendingError extends Error { + constructor(status: string) { + super(`Proposal cannot be accepted from status '${status}'`) + this.name = 'ProposalNotPendingError' + } +} + +export class ProjectAlreadyContractedError extends Error { + constructor(projectId: string) { + super(`Project ${projectId} already has an accepted proposal`) + this.name = 'ProjectAlreadyContractedError' + } +} + +export class ProjectNotActiveError extends Error { + constructor(status: string) { + super(`Project cannot accept proposals in status '${status}'`) + this.name = 'ProjectNotActiveError' + } +} + +export class UnauthorizedAcceptanceError extends Error { + constructor() { + super('Only the project client can accept a proposal') + this.name = 'UnauthorizedAcceptanceError' + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function rowToProposal(row: Record): Proposal { + return { + id: row.id as string, + projectId: row.project_id as string, + freelancerId: row.freelancer_id as string, + coverLetter: row.cover_letter as string, + proposedAmount: Number(row.proposed_amount), + currency: row.currency as string, + estimatedDuration: (row.estimated_duration as string | null) ?? null, + milestoneBreakdown: (row.milestone_breakdown as ProposalMilestone[]) ?? [], + status: row.status as ProposalStatus, + contractId: (row.contract_id as string | null) ?? null, + acceptedAt: + row.accepted_at instanceof Date + ? row.accepted_at.toISOString() + : (row.accepted_at as string | null) ?? null, + rejectedAt: + row.rejected_at instanceof Date + ? row.rejected_at.toISOString() + : (row.rejected_at as string | null) ?? null, + createdAt: + row.created_at instanceof Date + ? row.created_at.toISOString() + : (row.created_at as string), + updatedAt: + row.updated_at instanceof Date + ? row.updated_at.toISOString() + : (row.updated_at as string), + } +} + +/** Returns a direct Neon connection that supports transaction-mode queries. */ +function getTxDb() { + const url = process.env.DATABASE_URL + if (!url) { + throw new Error( + 'DATABASE_URL environment variable is not set. ' + + 'Copy env.example to .env and fill in your Neon connection string.', + ) + } + return neon(url) +} + +// ─── Public API ─────────────────────────────────────────────────────────── + +/** + * Fetches a single proposal by its UUID. Returns null if not found. + */ +export async function getProposalById(id: string): Promise { + const db = getTxDb() + const rows = (await db('SELECT * FROM proposals WHERE id = $1 LIMIT 1', [id])) as Record< + string, + unknown + >[] + if (rows.length === 0) return null + return rowToProposal(rows[0]) +} + +/** + * Atomically accepts a freelancer proposal and creates the resulting + * contract + milestone records. + * + * Steps (all within a single DB transaction): + * 1. Lock & validate the proposal (must be 'pending'). + * 2. Verify the caller is the project's client. + * 3. Verify the project is in an acceptable state (open / in_progress). + * 4. Mark the proposal 'accepted'. + * 5. Reject all other pending proposals for the same project. + * 6. Create the contract record (status='pending', escrow_status='unfunded'). + * 7. Update the proposal's contract_id back-reference. + * 8. Create milestone rows from proposal.milestone_breakdown. + * 9. Advance the project status to 'in_progress'. + * 10. Insert an admin_audit_log entry for traceability. + * + * @param proposalId UUID of the proposal to accept. + * @param clientId UUID of the authenticated user (must be project client). + * @returns The updated proposal, the new contract, and the milestones. + */ +export async function acceptProposal( + proposalId: string, + clientId: string, +): Promise { + const db = getTxDb() + + // ── Begin transaction ──────────────────────────────────────────────────── + await db('BEGIN', []) + + try { + // ── Step 1: Lock & fetch the proposal ─────────────────────────────────── + const proposalRows = (await db( + 'SELECT * FROM proposals WHERE id = $1 FOR UPDATE', + [proposalId], + )) as Record[] + + if (proposalRows.length === 0) { + throw new ProposalNotFoundError(proposalId) + } + + const proposalRow = proposalRows[0] + + if (proposalRow.status === 'accepted') { + throw new ProposalAlreadyAcceptedError(proposalId) + } + if (proposalRow.status !== 'pending') { + throw new ProposalNotPendingError(proposalRow.status as string) + } + + const projectId = proposalRow.project_id as string + const freelancerId = proposalRow.freelancer_id as string + const proposedAmount = Number(proposalRow.proposed_amount) + const currency = proposalRow.currency as string + const milestoneBreakdown = (proposalRow.milestone_breakdown as ProposalMilestone[]) ?? [] + + // ── Step 2: Verify caller is the project client ────────────────────────── + const projectRows = (await db( + 'SELECT id, client_id, status FROM projects WHERE id = $1 FOR UPDATE', + [projectId], + )) as Record[] + + if (projectRows.length === 0) { + throw new ProposalNotFoundError(projectId) // project vanished + } + + const project = projectRows[0] + + if (project.client_id !== clientId) { + throw new UnauthorizedAcceptanceError() + } + + // ── Step 3: Validate project state ────────────────────────────────────── + const projectStatus = project.status as string + if (!['open', 'in_progress', 'draft'].includes(projectStatus)) { + throw new ProjectNotActiveError(projectStatus) + } + + // ── Step 4: Mark proposal as accepted ─────────────────────────────────── + const acceptedRows = (await db( + `UPDATE proposals + SET status = 'accepted', + accepted_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [proposalId], + )) as Record[] + + const acceptedProposal = acceptedRows[0] + + // ── Step 5: Reject remaining pending proposals for this project ───────── + await db( + `UPDATE proposals + SET status = 'rejected', + rejected_at = NOW(), + updated_at = NOW() + WHERE project_id = $1 + AND id != $2 + AND status = 'pending'`, + [projectId, proposalId], + ) + + // ── Step 6: Create contract ────────────────────────────────────────────── + const contractRows = (await db( + `INSERT INTO contracts ( + project_id, client_id, freelancer_id, + total_amount, currency, + status, escrow_status + ) VALUES ($1, $2, $3, $4, $5, 'pending', 'unfunded') + RETURNING *`, + [projectId, clientId, freelancerId, proposedAmount, currency], + )) as Record[] + + const contractRow = contractRows[0] + const contractId = contractRow.id as string + + // ── Step 7: Back-reference proposal → contract ─────────────────────────── + await db( + `UPDATE proposals + SET contract_id = $1, + updated_at = NOW() + WHERE id = $2`, + [contractId, proposalId], + ) + + // ── Step 8: Create milestones from breakdown ───────────────────────────── + const createdMilestones: Array<{ + id: string + contractId: string + projectId: string + title: string + description: string | null + amount: number + currency: string + dueDate: string | null + sortOrder: number + status: string + createdAt: string + }> = [] + + for (let i = 0; i < milestoneBreakdown.length; i++) { + const m = milestoneBreakdown[i] + const milestoneRows = (await db( + `INSERT INTO milestones ( + project_id, contract_id, + title, description, + amount, currency, + due_date, sort_order, + status + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending') + RETURNING *`, + [ + projectId, + contractId, + m.title, + m.description ?? null, + m.amount, + currency, + m.due_date ?? null, + i, + ], + )) as Record[] + + const mr = milestoneRows[0] + createdMilestones.push({ + id: mr.id as string, + contractId: mr.contract_id as string, + projectId: mr.project_id as string, + title: mr.title as string, + description: (mr.description as string | null) ?? null, + amount: Number(mr.amount), + currency: mr.currency as string, + dueDate: + mr.due_date instanceof Date + ? mr.due_date.toISOString() + : (mr.due_date as string | null) ?? null, + sortOrder: Number(mr.sort_order), + status: mr.status as string, + createdAt: + mr.created_at instanceof Date + ? mr.created_at.toISOString() + : (mr.created_at as string), + }) + } + + // ── Step 9: Advance project to 'in_progress' ──────────────────────────── + await db( + `UPDATE projects + SET status = 'in_progress', + started_at = NOW(), + updated_at = NOW() + WHERE id = $1`, + [projectId], + ) + + // ── Step 10: Audit log ─────────────────────────────────────────────────── + await db( + `INSERT INTO admin_audit_logs ( + admin_user_id, action, + target_type, target_id, details + ) VALUES ($1, 'PROPOSAL_ACCEPTED', 'contract', $2, $3::jsonb)`, + [ + clientId, + contractId, + JSON.stringify({ + proposalId, + projectId, + freelancerId, + contractId, + totalAmount: proposedAmount, + currency, + milestoneCount: milestoneBreakdown.length, + timestamp: new Date().toISOString(), + }), + ], + ) + + // ── Commit ─────────────────────────────────────────────────────────────── + await db('COMMIT', []) + + // ── Build return value ─────────────────────────────────────────────────── + const finalProposal = rowToProposal({ + ...acceptedProposal, + contract_id: contractId, + }) + + const contract = { + id: contractId, + projectId: contractRow.project_id as string, + clientId: contractRow.client_id as string, + freelancerId: contractRow.freelancer_id as string, + status: contractRow.status as string, + escrowStatus: contractRow.escrow_status as string, + totalAmount: Number(contractRow.total_amount), + currency: contractRow.currency as string, + proposalId, + createdAt: + contractRow.created_at instanceof Date + ? contractRow.created_at.toISOString() + : (contractRow.created_at as string), + updatedAt: + contractRow.updated_at instanceof Date + ? contractRow.updated_at.toISOString() + : (contractRow.updated_at as string), + } + + return { proposal: finalProposal, contract, milestones: createdMilestones } + } catch (err) { + // Roll back all changes on any error + await db('ROLLBACK', []) + throw err + } +} diff --git a/lib/validations/index.ts b/lib/validations/index.ts index 285c2b1..fa8135d 100644 --- a/lib/validations/index.ts +++ b/lib/validations/index.ts @@ -186,3 +186,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; +$$;