From 43bd993e942443f8e1a54cbcbd4208ec02309a4f Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:34 +0100 Subject: [PATCH 1/9] feat(proposals): add lib/proposals/types.ts --- lib/proposals/types.ts | 137 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 lib/proposals/types.ts diff --git a/lib/proposals/types.ts b/lib/proposals/types.ts new file mode 100644 index 0000000..2713d9f --- /dev/null +++ b/lib/proposals/types.ts @@ -0,0 +1,137 @@ +/** + * Proposal Acceptance & Contract Creation — shared types. + * + * Models the flow: accept proposal → validate project/job → create contract → + * seed milestones → initialize escrow as Pending Funding, atomically. + */ + +export type ProposalStatus = 'pending' | 'accepted' | 'rejected' + +export type JobStatus = + | 'open' + | 'assigned' + | 'in_progress' + | 'in_review' + | 'completed' + | 'cancelled' + | 'disputed' + +/** Contract lifecycle after acceptance (Draft / Pending Funding). */ +export type AcceptedContractStatus = 'pending' | 'draft' | 'pending_funding' + +export type EscrowInitStatus = 'pending' + +export interface ProposalMilestoneBreakdown { + title: string + description?: string + amount: string + dueDate?: string +} + +export interface ProposalRecord { + id: number + jobId: number + freelancerId: number + coverLetter: string + proposedBudget: string + estimatedDuration: string | null + status: ProposalStatus + /** Optional milestone plan supplied with the proposal. */ + milestoneBreakdown: ProposalMilestoneBreakdown[] + createdAt: string + updatedAt: string +} + +export interface JobRecord { + id: number + clientId: number + freelancerId: number | null + title: string + status: JobStatus + budget: string + currency: string + escrowContractId: string | null + escrowStatus: string | null +} + +export interface ContractRecord { + id: number + jobId: number + proposalId: number + clientId: number + freelancerId: number + totalAmount: string + currency: string + terms: string | null + status: AcceptedContractStatus + createdAt: string + updatedAt: string +} + +export interface MilestoneRecord { + id: number + jobId: number + contractId: number + title: string + description: string | null + amount: string + status: string + dueDate: string | null +} + +export interface AcceptanceAuditEvent { + event: + | 'proposal_accepted' + | 'contract_created' + | 'milestones_created' + | 'escrow_initialized' + actorUserId: number + proposalId: number + jobId: number + contractId?: number + metadata?: Record + createdAt: string +} + +export interface AcceptProposalInput { + proposalId: number + /** Authenticated client user id (must own the job). */ + actorUserId: number + /** Optional override terms stored on the contract. */ + terms?: string + /** + * Optional milestone plan. When omitted, uses `proposal.milestoneBreakdown` + * if present; otherwise a single milestone for the full proposed budget. + */ + milestones?: ProposalMilestoneBreakdown[] +} + +export interface AcceptProposalResult { + proposal: ProposalRecord + job: JobRecord + contract: ContractRecord + milestones: MilestoneRecord[] + escrowStatus: EscrowInitStatus + auditEvents: AcceptanceAuditEvent[] +} + +/** Repository surface — swap with an in-memory fake in unit tests. */ +export interface IProposalAcceptanceRepository { + getProposalById(proposalId: number): Promise + getJobById(jobId: number): Promise + getAcceptedProposalForJob(jobId: number): Promise + getContractByJobId(jobId: number): Promise + getUserType(userId: number): Promise + /** + * Atomically: accept proposal, reject siblings, create contract + milestones, + * assign job, init escrow, write audit events. Must roll back on any failure. + */ + commitAcceptance(params: { + proposal: ProposalRecord + job: JobRecord + actorUserId: number + terms: string | null + milestones: ProposalMilestoneBreakdown[] + contractStatus: AcceptedContractStatus + }): Promise +} From 5db20a3d45cd7b25ebb148fb6171ba4228bdc7f5 Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:37 +0100 Subject: [PATCH 2/9] feat(proposals): add lib/proposals/errors.ts --- lib/proposals/errors.ts | 113 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 lib/proposals/errors.ts diff --git a/lib/proposals/errors.ts b/lib/proposals/errors.ts new file mode 100644 index 0000000..41b9085 --- /dev/null +++ b/lib/proposals/errors.ts @@ -0,0 +1,113 @@ +/** + * Typed errors for the Proposal Acceptance service. + * Controllers map these to HTTP status codes via `proposalErrorToHttpStatus`. + */ + +export type ProposalErrorCode = + | 'PROPOSAL_NOT_FOUND' + | 'JOB_NOT_FOUND' + | 'PROPOSAL_NOT_PENDING' + | 'JOB_NOT_ACCEPTING' + | 'PROPOSAL_ALREADY_ACCEPTED' + | 'CONTRACT_ALREADY_EXISTS' + | 'FORBIDDEN' + | 'VALIDATION' + | 'TRANSACTION_FAILED' + +export class ProposalAcceptanceError extends Error { + readonly code: ProposalErrorCode + readonly httpStatus: number + + constructor(message: string, code: ProposalErrorCode, httpStatus: number) { + super(message) + this.name = 'ProposalAcceptanceError' + this.code = code + this.httpStatus = httpStatus + } +} + +export class ProposalNotFoundError extends ProposalAcceptanceError { + constructor(proposalId: number) { + super(`Proposal ${proposalId} was not found`, 'PROPOSAL_NOT_FOUND', 404) + this.name = 'ProposalNotFoundError' + } +} + +export class JobNotFoundError extends ProposalAcceptanceError { + constructor(jobId: number) { + super(`Job/project ${jobId} was not found`, 'JOB_NOT_FOUND', 404) + this.name = 'JobNotFoundError' + } +} + +export class ProposalNotPendingError extends ProposalAcceptanceError { + constructor(proposalId: number, status: string) { + super( + `Proposal ${proposalId} cannot be accepted (status=${status})`, + 'PROPOSAL_NOT_PENDING', + 409 + ) + this.name = 'ProposalNotPendingError' + } +} + +export class JobNotAcceptingError extends ProposalAcceptanceError { + constructor(jobId: number, status: string) { + super( + `Job ${jobId} is not open for proposal acceptance (status=${status})`, + 'JOB_NOT_ACCEPTING', + 409 + ) + this.name = 'JobNotAcceptingError' + } +} + +export class ProposalAlreadyAcceptedError extends ProposalAcceptanceError { + constructor(jobId: number, existingProposalId: number) { + super( + `Job ${jobId} already has accepted proposal ${existingProposalId}`, + 'PROPOSAL_ALREADY_ACCEPTED', + 409 + ) + this.name = 'ProposalAlreadyAcceptedError' + } +} + +export class ContractAlreadyExistsError extends ProposalAcceptanceError { + constructor(jobId: number, contractId: number) { + super( + `Job ${jobId} already has contract ${contractId}`, + 'CONTRACT_ALREADY_EXISTS', + 409 + ) + this.name = 'ContractAlreadyExistsError' + } +} + +export class ProposalForbiddenError extends ProposalAcceptanceError { + constructor(message: string) { + super(message, 'FORBIDDEN', 403) + this.name = 'ProposalForbiddenError' + } +} + +export class ProposalValidationError extends ProposalAcceptanceError { + constructor(message: string) { + super(message, 'VALIDATION', 400) + this.name = 'ProposalValidationError' + } +} + +export class ProposalTransactionError extends ProposalAcceptanceError { + readonly cause: unknown + + constructor(message: string, cause?: unknown) { + super(message, 'TRANSACTION_FAILED', 500) + this.name = 'ProposalTransactionError' + this.cause = cause + } +} + +export function proposalErrorToHttpStatus(err: ProposalAcceptanceError): number { + return err.httpStatus +} From 75687394f3a1e91a69b5307b40850740fd2898ff Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:39 +0100 Subject: [PATCH 3/9] feat(proposals): add lib/proposals/repository.ts --- lib/proposals/repository.ts | 318 ++++++++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 lib/proposals/repository.ts diff --git a/lib/proposals/repository.ts b/lib/proposals/repository.ts new file mode 100644 index 0000000..f9c3076 --- /dev/null +++ b/lib/proposals/repository.ts @@ -0,0 +1,318 @@ +/** + * Proposal Acceptance repository — Neon SQL + `sql.begin` for atomicity. + */ + +import { sql } from '@/lib/db' +import type { + AcceptProposalResult, + AcceptanceAuditEvent, + AcceptedContractStatus, + ContractRecord, + IProposalAcceptanceRepository, + JobRecord, + MilestoneRecord, + ProposalMilestoneBreakdown, + ProposalRecord, +} from './types' +import { ProposalTransactionError } from './errors' + +type SqlTxn = typeof sql + +function mapProposal(row: Record): ProposalRecord { + let breakdown: ProposalMilestoneBreakdown[] = [] + const raw = row.milestone_breakdown + if (Array.isArray(raw)) { + breakdown = raw as ProposalMilestoneBreakdown[] + } else if (typeof raw === 'string' && raw.trim()) { + try { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) breakdown = parsed + } catch { + breakdown = [] + } + } + + return { + id: Number(row.id), + jobId: Number(row.job_id), + freelancerId: Number(row.freelancer_id), + coverLetter: String(row.cover_letter ?? ''), + proposedBudget: String(row.proposed_budget), + estimatedDuration: row.estimated_duration != null ? String(row.estimated_duration) : null, + status: row.status as ProposalRecord['status'], + milestoneBreakdown: breakdown, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + } +} + +function mapJob(row: Record): JobRecord { + return { + id: Number(row.id), + clientId: Number(row.client_id), + freelancerId: row.freelancer_id != null ? Number(row.freelancer_id) : null, + title: String(row.title), + status: row.status as JobRecord['status'], + budget: String(row.budget), + currency: String(row.currency ?? 'XLM'), + escrowContractId: row.escrow_contract_id != null ? String(row.escrow_contract_id) : null, + escrowStatus: row.escrow_status != null ? String(row.escrow_status) : null, + } +} + +function mapContract(row: Record): ContractRecord { + return { + id: Number(row.id), + jobId: Number(row.job_id), + proposalId: Number(row.proposal_id), + clientId: Number(row.client_id), + freelancerId: Number(row.freelancer_id), + totalAmount: String(row.total_amount), + currency: String(row.currency), + terms: row.terms != null ? String(row.terms) : null, + status: row.status as AcceptedContractStatus, + createdAt: String(row.created_at), + updatedAt: String(row.updated_at), + } +} + +function mapMilestone(row: Record): MilestoneRecord { + return { + id: Number(row.id), + jobId: Number(row.job_id), + contractId: Number(row.contract_id), + title: String(row.title), + description: row.description != null ? String(row.description) : null, + amount: String(row.amount), + status: String(row.status), + dueDate: row.due_date != null ? String(row.due_date) : null, + } +} + +export class ProposalAcceptanceRepository implements IProposalAcceptanceRepository { + async getProposalById(proposalId: number): Promise { + const rows = (await sql` + SELECT id, job_id, freelancer_id, cover_letter, proposed_budget, + estimated_duration, status, milestone_breakdown, created_at, updated_at + FROM proposals + WHERE id = ${proposalId} + LIMIT 1 + `) as Record[] + return rows[0] ? mapProposal(rows[0]) : null + } + + async getJobById(jobId: number): Promise { + const rows = (await sql` + SELECT id, client_id, freelancer_id, title, status, budget, currency, + escrow_contract_id, escrow_status + FROM jobs + WHERE id = ${jobId} + LIMIT 1 + `) as Record[] + return rows[0] ? mapJob(rows[0]) : null + } + + async getAcceptedProposalForJob(jobId: number): Promise { + const rows = (await sql` + SELECT id, job_id, freelancer_id, cover_letter, proposed_budget, + estimated_duration, status, milestone_breakdown, created_at, updated_at + FROM proposals + WHERE job_id = ${jobId} AND status = 'accepted' + LIMIT 1 + `) as Record[] + return rows[0] ? mapProposal(rows[0]) : null + } + + async getContractByJobId(jobId: number): Promise { + const rows = (await sql` + SELECT id, job_id, proposal_id, client_id, freelancer_id, + total_amount, currency, terms, status, created_at, updated_at + FROM contracts + WHERE job_id = ${jobId} + LIMIT 1 + `) as Record[] + return rows[0] ? mapContract(rows[0]) : null + } + + async getUserType(userId: number): Promise { + const rows = (await sql` + SELECT user_type FROM users WHERE id = ${userId} LIMIT 1 + `) as { user_type: string }[] + return rows[0]?.user_type ?? null + } + + async commitAcceptance(params: { + proposal: ProposalRecord + job: JobRecord + actorUserId: number + terms: string | null + milestones: ProposalMilestoneBreakdown[] + contractStatus: AcceptedContractStatus + }): Promise { + const { proposal, job, actorUserId, terms, milestones, contractStatus } = params + const now = new Date().toISOString() + + try { + // Neon serverless supports transactional batches via sql.begin + const result = await (sql as SqlTxn & { + begin: (fn: (txn: SqlTxn) => Promise) => Promise + }).begin(async (txn) => { + // 1. Accept the chosen proposal + const acceptedRows = (await txn` + UPDATE proposals + SET status = 'accepted', + updated_at = CURRENT_TIMESTAMP + WHERE id = ${proposal.id} + AND status = 'pending' + RETURNING id, job_id, freelancer_id, cover_letter, proposed_budget, + estimated_duration, status, milestone_breakdown, created_at, updated_at + `) as Record[] + + if (acceptedRows.length === 0) { + throw new Error('Proposal was no longer pending at commit time') + } + const acceptedProposal = mapProposal(acceptedRows[0]) + + // 2. Reject sibling proposals for the same job + await txn` + UPDATE proposals + SET status = 'rejected', + updated_at = CURRENT_TIMESTAMP + WHERE job_id = ${job.id} + AND id <> ${proposal.id} + AND status = 'pending' + ` + + // 3. Create contract linked to the proposal + const contractRows = (await txn` + INSERT INTO contracts ( + job_id, proposal_id, client_id, freelancer_id, + total_amount, currency, terms, status + ) + VALUES ( + ${job.id}, + ${proposal.id}, + ${job.clientId}, + ${proposal.freelancerId}, + ${proposal.proposedBudget}, + ${job.currency}, + ${terms}, + ${contractStatus} + ) + RETURNING id, job_id, proposal_id, client_id, freelancer_id, + total_amount, currency, terms, status, created_at, updated_at + `) as Record[] + const contract = mapContract(contractRows[0]) + + // 4. Seed milestones from the breakdown + const createdMilestones: MilestoneRecord[] = [] + for (const m of milestones) { + const milestoneRows = (await txn` + INSERT INTO milestones ( + job_id, contract_id, title, description, amount, due_date, status + ) + VALUES ( + ${job.id}, + ${contract.id}, + ${m.title}, + ${m.description ?? null}, + ${m.amount}, + ${m.dueDate ?? null}, + 'pending' + ) + RETURNING id, job_id, contract_id, title, description, amount, status, due_date + `) as Record[] + createdMilestones.push(mapMilestone(milestoneRows[0])) + } + + // 5. Assign job + initialize escrow as Pending Funding + const jobRows = (await txn` + UPDATE jobs + SET freelancer_id = ${proposal.freelancerId}, + status = 'assigned', + escrow_status = 'pending', + updated_at = CURRENT_TIMESTAMP + WHERE id = ${job.id} + RETURNING id, client_id, freelancer_id, title, status, budget, currency, + escrow_contract_id, escrow_status + `) as Record[] + const updatedJob = mapJob(jobRows[0]) + + // 6. Audit trail + const auditEvents: AcceptanceAuditEvent[] = [ + { + event: 'proposal_accepted', + actorUserId, + proposalId: proposal.id, + jobId: job.id, + contractId: contract.id, + metadata: { previousStatus: 'pending', newStatus: 'accepted' }, + createdAt: now, + }, + { + event: 'contract_created', + actorUserId, + proposalId: proposal.id, + jobId: job.id, + contractId: contract.id, + metadata: { status: contractStatus, totalAmount: contract.totalAmount }, + createdAt: now, + }, + { + event: 'milestones_created', + actorUserId, + proposalId: proposal.id, + jobId: job.id, + contractId: contract.id, + metadata: { count: createdMilestones.length }, + createdAt: now, + }, + { + event: 'escrow_initialized', + actorUserId, + proposalId: proposal.id, + jobId: job.id, + contractId: contract.id, + metadata: { escrowStatus: 'pending' }, + createdAt: now, + }, + ] + + for (const ev of auditEvents) { + await txn` + INSERT INTO proposal_acceptance_audit ( + event, actor_user_id, proposal_id, job_id, contract_id, metadata + ) + VALUES ( + ${ev.event}, + ${ev.actorUserId}, + ${ev.proposalId}, + ${ev.jobId}, + ${ev.contractId ?? null}, + ${JSON.stringify(ev.metadata ?? {})}::jsonb + ) + ` + } + + return { + proposal: acceptedProposal, + job: updatedJob, + contract, + milestones: createdMilestones, + escrowStatus: 'pending' as const, + auditEvents, + } + }) + + return result + } catch (err) { + if (err instanceof ProposalTransactionError) throw err + throw new ProposalTransactionError( + 'Proposal acceptance transaction failed and was rolled back', + err + ) + } + } +} + +export const proposalAcceptanceRepository = new ProposalAcceptanceRepository() From e679a1419e1be9d2d6abee3cd55f830e40ac9bbd Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:42 +0100 Subject: [PATCH 4/9] feat(proposals): add lib/proposals/service.ts --- lib/proposals/service.ts | 165 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 lib/proposals/service.ts diff --git a/lib/proposals/service.ts b/lib/proposals/service.ts new file mode 100644 index 0000000..4f67a9e --- /dev/null +++ b/lib/proposals/service.ts @@ -0,0 +1,165 @@ +/** + * Proposal Acceptance & Contract Creation Service + * + * Orchestrates client acceptance of a freelancer proposal into a contract: + * 1. Authorize — only the job's client may accept + * 2. Validate — proposal pending, job open/active, no existing accepted proposal/contract + * 3. Commit atomically — accept → contract → milestones → escrow pending funding + * + * Controllers call this service; they never touch SQL directly. + */ + +import type { + AcceptProposalInput, + AcceptProposalResult, + AcceptedContractStatus, + IProposalAcceptanceRepository, + ProposalMilestoneBreakdown, +} from './types' +import { + ProposalAlreadyAcceptedError, + ContractAlreadyExistsError, + JobNotAcceptingError, + JobNotFoundError, + ProposalForbiddenError, + ProposalNotFoundError, + ProposalNotPendingError, + ProposalValidationError, +} from './errors' +import { proposalAcceptanceRepository } from './repository' + +const ACCEPTABLE_JOB_STATUSES = new Set(['open']) + +const AMOUNT_RE = /^\d+(\.\d{1,6})?$/ + +export class ProposalAcceptanceService { + constructor( + private readonly repo: IProposalAcceptanceRepository = proposalAcceptanceRepository + ) {} + + /** + * Accept a proposal and create the linked contract + milestones + escrow init. + * All persistence happens inside a single DB transaction (see repository). + */ + async acceptProposal(input: AcceptProposalInput): Promise { + this.assertValidInput(input) + + const proposal = await this.repo.getProposalById(input.proposalId) + if (!proposal) { + throw new ProposalNotFoundError(input.proposalId) + } + if (proposal.status !== 'pending') { + throw new ProposalNotPendingError(proposal.id, proposal.status) + } + + const job = await this.repo.getJobById(proposal.jobId) + if (!job) { + throw new JobNotFoundError(proposal.jobId) + } + if (!ACCEPTABLE_JOB_STATUSES.has(job.status)) { + throw new JobNotAcceptingError(job.id, job.status) + } + + // Authorization: only the client who owns the job may accept. + if (job.clientId !== input.actorUserId) { + throw new ProposalForbiddenError( + 'Only the project client can accept proposals and create contracts' + ) + } + + // Freelancers cannot trigger contract creation even if they somehow pass clientId. + const userType = await this.repo.getUserType(input.actorUserId) + if (userType === 'freelancer') { + throw new ProposalForbiddenError( + 'Freelancers cannot accept proposals or create contracts' + ) + } + + const existingAccepted = await this.repo.getAcceptedProposalForJob(job.id) + if (existingAccepted) { + throw new ProposalAlreadyAcceptedError(job.id, existingAccepted.id) + } + + const existingContract = await this.repo.getContractByJobId(job.id) + if (existingContract) { + throw new ContractAlreadyExistsError(job.id, existingContract.id) + } + + const milestones = this.resolveMilestones(input.milestones, proposal.milestoneBreakdown, proposal.proposedBudget) + this.assertMilestonesMatchBudget(milestones, proposal.proposedBudget) + + const contractStatus: AcceptedContractStatus = 'pending' + + return this.repo.commitAcceptance({ + proposal, + job, + actorUserId: input.actorUserId, + terms: input.terms?.trim() || proposal.coverLetter || null, + milestones, + contractStatus, + }) + } + + private assertValidInput(input: AcceptProposalInput): void { + if (!Number.isInteger(input.proposalId) || input.proposalId <= 0) { + throw new ProposalValidationError('proposalId must be a positive integer') + } + if (!Number.isInteger(input.actorUserId) || input.actorUserId <= 0) { + throw new ProposalValidationError('actorUserId must be a positive integer') + } + } + + private resolveMilestones( + override: ProposalMilestoneBreakdown[] | undefined, + fromProposal: ProposalMilestoneBreakdown[], + proposedBudget: string + ): ProposalMilestoneBreakdown[] { + if (override && override.length > 0) { + return override.map((m) => this.normalizeMilestone(m)) + } + if (fromProposal && fromProposal.length > 0) { + return fromProposal.map((m) => this.normalizeMilestone(m)) + } + // Default: single milestone covering the full proposed budget + return [ + { + title: 'Project delivery', + description: 'Initial milestone created from accepted proposal', + amount: proposedBudget, + }, + ] + } + + private normalizeMilestone(m: ProposalMilestoneBreakdown): ProposalMilestoneBreakdown { + if (!m.title?.trim()) { + throw new ProposalValidationError('Each milestone requires a non-empty title') + } + if (!AMOUNT_RE.test(m.amount) || Number(m.amount) <= 0) { + throw new ProposalValidationError( + `Milestone "${m.title}" has an invalid amount: ${m.amount}` + ) + } + return { + title: m.title.trim(), + description: m.description?.trim() || undefined, + amount: m.amount, + dueDate: m.dueDate, + } + } + + private assertMilestonesMatchBudget( + milestones: ProposalMilestoneBreakdown[], + proposedBudget: string + ): void { + const sum = milestones.reduce((acc, m) => acc + Number(m.amount), 0) + const budget = Number(proposedBudget) + // Allow 0.000001 tolerance for decimal string math + if (Math.abs(sum - budget) > 0.000001) { + throw new ProposalValidationError( + `Milestone amounts (${sum}) must equal the proposed budget (${budget})` + ) + } + } +} + +export const proposalAcceptanceService = new ProposalAcceptanceService() From bfd26b1c278d79e379e5f39dd41ba9d4bc0cc5ef Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:44 +0100 Subject: [PATCH 5/9] feat(proposals): add lib/proposals/index.ts --- lib/proposals/index.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 lib/proposals/index.ts diff --git a/lib/proposals/index.ts b/lib/proposals/index.ts new file mode 100644 index 0000000..0ae8cb4 --- /dev/null +++ b/lib/proposals/index.ts @@ -0,0 +1,42 @@ +/** + * Proposal Acceptance & Contract Creation — public API. + * + * import { + * proposalAcceptanceService, + * ProposalAcceptanceError, + * proposalErrorToHttpStatus, + * } from '@/lib/proposals' + */ + +export { proposalAcceptanceService, ProposalAcceptanceService } from './service' +export { proposalAcceptanceRepository, ProposalAcceptanceRepository } from './repository' + +export type { + ProposalStatus, + JobStatus, + AcceptedContractStatus, + EscrowInitStatus, + ProposalMilestoneBreakdown, + ProposalRecord, + JobRecord, + ContractRecord, + MilestoneRecord, + AcceptanceAuditEvent, + AcceptProposalInput, + AcceptProposalResult, + IProposalAcceptanceRepository, +} from './types' + +export { + ProposalAcceptanceError, + ProposalNotFoundError, + JobNotFoundError, + ProposalNotPendingError, + JobNotAcceptingError, + ProposalAlreadyAcceptedError, + ContractAlreadyExistsError, + ProposalForbiddenError, + ProposalValidationError, + ProposalTransactionError, + proposalErrorToHttpStatus, +} from './errors' From 1c97882777c96ba0d0477708b128e9651052d75c Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:48 +0100 Subject: [PATCH 6/9] feat(proposals): add scripts/013-proposal-acceptance.sql --- scripts/013-proposal-acceptance.sql | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 scripts/013-proposal-acceptance.sql diff --git a/scripts/013-proposal-acceptance.sql b/scripts/013-proposal-acceptance.sql new file mode 100644 index 0000000..8b575ae --- /dev/null +++ b/scripts/013-proposal-acceptance.sql @@ -0,0 +1,62 @@ +-- Proposal Acceptance & Contract Creation support +-- Extends contracts with proposal linkage, optional milestone breakdown on +-- proposals, uniqueness of one accepted proposal per job, and an audit table. + +-- 1. Optional milestone plan on proposals (JSON array of {title, amount, ...}) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'proposals' AND column_name = 'milestone_breakdown' + ) THEN + ALTER TABLE proposals + ADD COLUMN milestone_breakdown JSONB NOT NULL DEFAULT '[]'::jsonb; + END IF; +END; +$$; + +-- 2. Contract → Proposal one-to-one linkage +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'contracts' AND column_name = 'proposal_id' + ) THEN + ALTER TABLE contracts + ADD COLUMN proposal_id INTEGER REFERENCES proposals(id) ON DELETE RESTRICT; + CREATE UNIQUE INDEX IF NOT EXISTS uq_contracts_proposal + ON contracts(proposal_id) + WHERE proposal_id IS NOT NULL; + END IF; +END; +$$; + +-- 3. At most one accepted proposal per job +CREATE UNIQUE INDEX IF NOT EXISTS uq_proposals_one_accepted_per_job + ON proposals(job_id) + WHERE status = 'accepted'; + +-- 4. Audit trail for acceptance / contract / escrow init events +CREATE TABLE IF NOT EXISTS proposal_acceptance_audit ( + id SERIAL PRIMARY KEY, + event VARCHAR(40) NOT NULL + CHECK (event IN ( + 'proposal_accepted', + 'contract_created', + 'milestones_created', + 'escrow_initialized' + )), + actor_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + contract_id INTEGER REFERENCES contracts(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_proposal_acceptance_audit_proposal + ON proposal_acceptance_audit(proposal_id); +CREATE INDEX IF NOT EXISTS idx_proposal_acceptance_audit_job + ON proposal_acceptance_audit(job_id); +CREATE INDEX IF NOT EXISTS idx_proposal_acceptance_audit_created + ON proposal_acceptance_audit(created_at DESC); From 1ee2d102ab51ff38ea1c69cf4f36a01f4e59b9e5 Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:53 +0100 Subject: [PATCH 7/9] feat(proposals): add lib/db/migrations/013_proposal_acceptance.sql --- lib/db/migrations/013_proposal_acceptance.sql | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 lib/db/migrations/013_proposal_acceptance.sql diff --git a/lib/db/migrations/013_proposal_acceptance.sql b/lib/db/migrations/013_proposal_acceptance.sql new file mode 100644 index 0000000..c8552d8 --- /dev/null +++ b/lib/db/migrations/013_proposal_acceptance.sql @@ -0,0 +1,57 @@ +-- Mirror of scripts/013-proposal-acceptance.sql for the migrations runner. +-- See that file for full commentary. + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'proposals' AND column_name = 'milestone_breakdown' + ) THEN + ALTER TABLE proposals + ADD COLUMN milestone_breakdown JSONB NOT NULL DEFAULT '[]'::jsonb; + END IF; +END; +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'contracts' AND column_name = 'proposal_id' + ) THEN + ALTER TABLE contracts + ADD COLUMN proposal_id INTEGER REFERENCES proposals(id) ON DELETE RESTRICT; + CREATE UNIQUE INDEX IF NOT EXISTS uq_contracts_proposal + ON contracts(proposal_id) + WHERE proposal_id IS NOT NULL; + END IF; +END; +$$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_proposals_one_accepted_per_job + ON proposals(job_id) + WHERE status = 'accepted'; + +CREATE TABLE IF NOT EXISTS proposal_acceptance_audit ( + id SERIAL PRIMARY KEY, + event VARCHAR(40) NOT NULL + CHECK (event IN ( + 'proposal_accepted', + 'contract_created', + 'milestones_created', + 'escrow_initialized' + )), + actor_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + proposal_id INTEGER NOT NULL REFERENCES proposals(id) ON DELETE CASCADE, + job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + contract_id INTEGER REFERENCES contracts(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_proposal_acceptance_audit_proposal + ON proposal_acceptance_audit(proposal_id); +CREATE INDEX IF NOT EXISTS idx_proposal_acceptance_audit_job + ON proposal_acceptance_audit(job_id); +CREATE INDEX IF NOT EXISTS idx_proposal_acceptance_audit_created + ON proposal_acceptance_audit(created_at DESC); From d58020a8d31c381cd305d33fd8e98a98e574d951 Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:55 +0100 Subject: [PATCH 8/9] feat(proposals): add app/api/proposals/[id]/accept/route.ts --- app/api/proposals/[id]/accept/route.ts | 97 ++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 app/api/proposals/[id]/accept/route.ts diff --git a/app/api/proposals/[id]/accept/route.ts b/app/api/proposals/[id]/accept/route.ts new file mode 100644 index 0000000..d97c773 --- /dev/null +++ b/app/api/proposals/[id]/accept/route.ts @@ -0,0 +1,97 @@ +/** + * POST /api/proposals/[id]/accept + * + * Client accepts a freelancer proposal. Atomically: + * - marks the proposal Accepted (rejects siblings) + * - creates a Draft / Pending Funding contract linked to the proposal + * - seeds milestones from the proposal breakdown (or request body) + * - initializes job escrow_status to pending (Pending Funding) + * + * Only the job's client may call this endpoint. Freelancers are rejected. + */ + +import { NextRequest, NextResponse } from 'next/server' +import { withAuthCtx, resolveUserIdByWallet } from '@/lib/auth/middleware' +import { + proposalAcceptanceService, + ProposalAcceptanceError, + proposalErrorToHttpStatus, + type ProposalMilestoneBreakdown, +} from '@/lib/proposals' + +type RouteCtx = { params: Promise<{ id: string }> } + +export const POST = withAuthCtx(async (request: NextRequest, auth, context: RouteCtx) => { + const { id: idParam } = await context.params + const proposalId = Number(idParam) + if (!Number.isInteger(proposalId) || proposalId <= 0) { + return NextResponse.json( + { error: 'Invalid proposal id', code: 'VALIDATION' }, + { status: 400 } + ) + } + + const actorUserId = await resolveUserIdByWallet(auth.walletAddress) + if (actorUserId == null) { + return NextResponse.json( + { error: 'Authenticated wallet has no platform account', code: 'USER_NOT_FOUND' }, + { status: 401 } + ) + } + + let body: Record = {} + try { + const text = await request.text() + if (text.trim()) { + body = JSON.parse(text) as Record + } + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + + try { + const result = await proposalAcceptanceService.acceptProposal({ + proposalId, + actorUserId, + terms: typeof body.terms === 'string' ? body.terms : undefined, + milestones: body.milestones as ProposalMilestoneBreakdown[] | undefined, + }) + + return NextResponse.json( + { + proposalId: result.proposal.id, + proposalStatus: result.proposal.status, + jobId: result.job.id, + jobStatus: result.job.status, + contractId: result.contract.id, + contractStatus: result.contract.status, + proposalLinked: result.contract.proposalId === result.proposal.id, + escrowStatus: result.escrowStatus, + milestonesCreated: result.milestones.length, + milestones: result.milestones.map((m) => ({ + id: m.id, + title: m.title, + amount: m.amount, + status: m.status, + })), + auditEvents: result.auditEvents.map((e) => e.event), + }, + { status: 201 } + ) + } catch (err) { + if (err instanceof ProposalAcceptanceError) { + return NextResponse.json( + { error: err.message, code: err.code }, + { status: proposalErrorToHttpStatus(err) } + ) + } + console.error('[proposals/accept] Unexpected error:', err) + return NextResponse.json( + { error: 'Internal server error', code: 'INTERNAL_ERROR' }, + { status: 500 } + ) + } +}) From 7c100cf6ef704e86c95093fa0d99c376f204b744 Mon Sep 17 00:00:00 2001 From: Jentle042 Date: Thu, 24 Sep 2026 17:08:58 +0100 Subject: [PATCH 9/9] feat(proposals): add __tests__/proposals/accept-service.test.ts --- __tests__/proposals/accept-service.test.ts | 268 +++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 __tests__/proposals/accept-service.test.ts diff --git a/__tests__/proposals/accept-service.test.ts b/__tests__/proposals/accept-service.test.ts new file mode 100644 index 0000000..682b9e7 --- /dev/null +++ b/__tests__/proposals/accept-service.test.ts @@ -0,0 +1,268 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { ProposalAcceptanceService } from '@/lib/proposals/service' +import type { + AcceptProposalResult, + ContractRecord, + IProposalAcceptanceRepository, + JobRecord, + ProposalMilestoneBreakdown, + ProposalRecord, +} from '@/lib/proposals/types' +import { + ProposalAlreadyAcceptedError, + ContractAlreadyExistsError, + JobNotAcceptingError, + ProposalForbiddenError, + ProposalNotPendingError, + ProposalValidationError, + ProposalTransactionError, +} from '@/lib/proposals/errors' + +function makeProposal(overrides: Partial = {}): ProposalRecord { + return { + id: 10, + jobId: 1, + freelancerId: 200, + coverLetter: 'I can deliver this well', + proposedBudget: '100.00', + estimatedDuration: '2 weeks', + status: 'pending', + milestoneBreakdown: [ + { title: 'Design', amount: '40.00' }, + { title: 'Build', amount: '60.00' }, + ], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + } +} + +function makeJob(overrides: Partial = {}): JobRecord { + return { + id: 1, + clientId: 100, + freelancerId: null, + title: 'Build TaskChain feature', + status: 'open', + budget: '100.00', + currency: 'XLM', + escrowContractId: null, + escrowStatus: null, + ...overrides, + } +} + +class FakeRepo implements IProposalAcceptanceRepository { + proposal: ProposalRecord | null = makeProposal() + job: JobRecord | null = makeJob() + accepted: ProposalRecord | null = null + contract: ContractRecord | null = null + userType: string | null = 'client' + failCommit = false + lastCommitMilestones: ProposalMilestoneBreakdown[] | null = null + + async getProposalById(id: number) { + return this.proposal && this.proposal.id === id ? this.proposal : null + } + async getJobById(id: number) { + return this.job && this.job.id === id ? this.job : null + } + async getAcceptedProposalForJob(_jobId: number) { + return this.accepted + } + async getContractByJobId(_jobId: number) { + return this.contract + } + async getUserType(_userId: number) { + return this.userType + } + async commitAcceptance(params: { + proposal: ProposalRecord + job: JobRecord + actorUserId: number + terms: string | null + milestones: ProposalMilestoneBreakdown[] + contractStatus: 'pending' | 'draft' | 'pending_funding' + }): Promise { + if (this.failCommit) { + throw new ProposalTransactionError('simulated rollback') + } + this.lastCommitMilestones = params.milestones + const now = new Date().toISOString() + const contract: ContractRecord = { + id: 55, + jobId: params.job.id, + proposalId: params.proposal.id, + clientId: params.job.clientId, + freelancerId: params.proposal.freelancerId, + totalAmount: params.proposal.proposedBudget, + currency: params.job.currency, + terms: params.terms, + status: params.contractStatus, + createdAt: now, + updatedAt: now, + } + return { + proposal: { ...params.proposal, status: 'accepted', updatedAt: now }, + job: { + ...params.job, + freelancerId: params.proposal.freelancerId, + status: 'assigned', + escrowStatus: 'pending', + }, + contract, + milestones: params.milestones.map((m, i) => ({ + id: i + 1, + jobId: params.job.id, + contractId: contract.id, + title: m.title, + description: m.description ?? null, + amount: m.amount, + status: 'pending', + dueDate: m.dueDate ?? null, + })), + escrowStatus: 'pending', + auditEvents: [ + { + event: 'proposal_accepted', + actorUserId: params.actorUserId, + proposalId: params.proposal.id, + jobId: params.job.id, + contractId: contract.id, + createdAt: now, + }, + { + event: 'contract_created', + actorUserId: params.actorUserId, + proposalId: params.proposal.id, + jobId: params.job.id, + contractId: contract.id, + createdAt: now, + }, + { + event: 'milestones_created', + actorUserId: params.actorUserId, + proposalId: params.proposal.id, + jobId: params.job.id, + contractId: contract.id, + metadata: { count: params.milestones.length }, + createdAt: now, + }, + { + event: 'escrow_initialized', + actorUserId: params.actorUserId, + proposalId: params.proposal.id, + jobId: params.job.id, + contractId: contract.id, + metadata: { escrowStatus: 'pending' }, + createdAt: now, + }, + ], + } + } +} + +describe('ProposalAcceptanceService', () => { + let repo: FakeRepo + let service: ProposalAcceptanceService + + beforeEach(() => { + repo = new FakeRepo() + service = new ProposalAcceptanceService(repo) + }) + + it('accepts a pending proposal and creates contract + milestones + escrow init', async () => { + const result = await service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + + expect(result.proposal.status).toBe('accepted') + expect(result.contract.proposalId).toBe(10) + expect(result.contract.status).toBe('pending') + expect(result.job.status).toBe('assigned') + expect(result.job.escrowStatus).toBe('pending') + expect(result.milestones).toHaveLength(2) + expect(result.auditEvents.map((e) => e.event)).toEqual([ + 'proposal_accepted', + 'contract_created', + 'milestones_created', + 'escrow_initialized', + ]) + }) + + it('rejects freelancers from accepting proposals', async () => { + repo.userType = 'freelancer' + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + ).rejects.toBeInstanceOf(ProposalForbiddenError) + }) + + it('rejects non-client actors who do not own the job', async () => { + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 999 }) + ).rejects.toBeInstanceOf(ProposalForbiddenError) + }) + + it('prevents accepting a non-pending proposal', async () => { + repo.proposal = makeProposal({ status: 'rejected' }) + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + ).rejects.toBeInstanceOf(ProposalNotPendingError) + }) + + it('prevents acceptance when the job is not open', async () => { + repo.job = makeJob({ status: 'completed' }) + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + ).rejects.toBeInstanceOf(JobNotAcceptingError) + }) + + it('enforces one accepted proposal per project/job', async () => { + repo.accepted = makeProposal({ id: 99, status: 'accepted' }) + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + ).rejects.toBeInstanceOf(ProposalAlreadyAcceptedError) + }) + + it('enforces one contract per job', async () => { + repo.contract = { + id: 7, + jobId: 1, + proposalId: 3, + clientId: 100, + freelancerId: 200, + totalAmount: '50', + currency: 'XLM', + terms: null, + status: 'pending', + createdAt: '', + updatedAt: '', + } + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + ).rejects.toBeInstanceOf(ContractAlreadyExistsError) + }) + + it('validates milestone amounts equal the proposed budget', async () => { + await expect( + service.acceptProposal({ + proposalId: 10, + actorUserId: 100, + milestones: [{ title: 'Only', amount: '10.00' }], + }) + ).rejects.toBeInstanceOf(ProposalValidationError) + }) + + it('defaults to a single full-budget milestone when none are provided', async () => { + repo.proposal = makeProposal({ milestoneBreakdown: [] }) + const result = await service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + expect(result.milestones).toHaveLength(1) + expect(result.milestones[0].amount).toBe('100.00') + expect(repo.lastCommitMilestones?.[0].title).toBe('Project delivery') + }) + + it('surfaces transaction failures so callers know the state was rolled back', async () => { + repo.failCommit = true + await expect( + service.acceptProposal({ proposalId: 10, actorUserId: 100 }) + ).rejects.toBeInstanceOf(ProposalTransactionError) + }) +})