diff --git a/lib/contracts/state-service.ts b/lib/contracts/state-service.ts new file mode 100644 index 0000000..4fc1e7f --- /dev/null +++ b/lib/contracts/state-service.ts @@ -0,0 +1,148 @@ +import { sql } from "@/lib/db"; +import { dispatchNotification } from "@/lib/notifications"; + +export type ContractStatus = + | 'draft' + | 'pending' + | 'pending_funding' + | 'active' + | 'submitted' + | 'completed' + | 'cancelled' + | 'disputed' + | 'paused'; + +export interface TransitionStateInput { + contractId: string; + newStatus: ContractStatus; + userId: string; + reason?: string; + contractName?: string; +} + +export class ContractStateError extends Error { + constructor(message: string) { + super(message); + this.name = 'ContractStateError'; + } +} + +// Define valid state transitions +const VALID_TRANSITIONS: Record = { + draft: ['pending_funding', 'cancelled'], + pending: ['active', 'cancelled', 'pending_funding'], // Legacy support + pending_funding: ['active', 'cancelled'], + active: ['submitted', 'disputed', 'cancelled', 'completed', 'paused'], + paused: ['active', 'cancelled', 'disputed'], + submitted: ['completed', 'active', 'disputed', 'cancelled'], + disputed: ['completed', 'cancelled', 'active'], + completed: [], // Terminal state + cancelled: [], // Terminal state +}; + +export class ContractStateService { + /** + * Transitions a contract from its current state to a new state. + * Enforces rules, logs the transition, and broadcasts events. + * + * @throws ContractStateError if the transition is invalid or contract not found + */ + async transitionState(input: TransitionStateInput): Promise { + const { contractId, newStatus, userId, reason } = input; + + // Fetch the current status and users + const rows = await sql` + SELECT status, client_id, freelancer_id + FROM contracts + WHERE id = ${contractId} + `; + + if (rows.length === 0) { + throw new ContractStateError(`Contract with id ${contractId} not found.`); + } + + const contract = rows[0]; + const currentStatus = contract.status as ContractStatus; + const clientId = contract.client_id as string; + const freelancerId = contract.freelancer_id as string; + + // Validate transition + if (currentStatus === newStatus) { + return; // No-op + } + + const allowedTransitions = VALID_TRANSITIONS[currentStatus] || []; + if (!allowedTransitions.includes(newStatus)) { + throw new ContractStateError( + `Invalid state transition from '${currentStatus}' to '${newStatus}'.` + ); + } + + // Execute updates within a transaction using our SQL template + await sql.begin(async (sqlTransaction) => { + // 1. Update the contract status + await sqlTransaction` + UPDATE contracts + SET status = ${newStatus}::contract_status, updated_at = NOW() + WHERE id = ${contractId} + `; + + // 2. Insert the audit log + await sqlTransaction` + INSERT INTO contract_state_logs ( + contract_id, previous_status, new_status, changed_by_user_id, reason + ) VALUES ( + ${contractId}, + ${currentStatus}::contract_status, + ${newStatus}::contract_status, + ${userId}, + ${reason || null} + ) + `; + }); + + // 3. Broadcast notifications outside the transaction to avoid rollback issues + const contractName = input.contractName || 'Contract'; + const notificationPayload = { + contractId, + contractName, + previousStatus: currentStatus, + newStatus, + reason, + }; + + // Notify client + if (userId !== clientId) { + await dispatchNotification(clientId, 'contract_state_changed', notificationPayload) + .catch(err => console.error(`Failed to notify client ${clientId}:`, err)); + } + + // Notify freelancer + if (userId !== freelancerId) { + await dispatchNotification(freelancerId, 'contract_state_changed', notificationPayload) + .catch(err => console.error(`Failed to notify freelancer ${freelancerId}:`, err)); + } + } + + /** + * Fetches the audit trail for a specific contract. + */ + async getContractStateLogs(contractId: string) { + const rows = await sql` + SELECT + l.id, + l.previous_status, + l.new_status, + l.reason, + l.created_at, + u.display_name as changed_by + FROM contract_state_logs l + LEFT JOIN users u ON l.changed_by_user_id = u.id + WHERE l.contract_id = ${contractId} + ORDER BY l.created_at DESC + `; + return rows; + } +} + +export const contractStateService = new ContractStateService(); diff --git a/lib/db/migrations/008_contract_lifecycle_states.sql b/lib/db/migrations/008_contract_lifecycle_states.sql new file mode 100644 index 0000000..bfc13c0 --- /dev/null +++ b/lib/db/migrations/008_contract_lifecycle_states.sql @@ -0,0 +1,32 @@ +-- Migration 008: Contract Lifecycle States + +-- Note: Postgres does not support adding values inside a transaction if the enum +-- is created in the same transaction. However, these ENUMs exist in 001. +-- ALTER TYPE ... ADD VALUE cannot be executed inside a transaction block +-- prior to Postgres 12. TaskChain likely uses modern Postgres, but we'll issue them individually. + +COMMIT; -- Ensure we are not in a transaction block if the runner wraps this + +ALTER TYPE contract_status ADD VALUE IF NOT EXISTS 'draft'; +ALTER TYPE contract_status ADD VALUE IF NOT EXISTS 'pending_funding'; +ALTER TYPE contract_status ADD VALUE IF NOT EXISTS 'submitted'; + +BEGIN; + +CREATE TABLE IF NOT EXISTS contract_state_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + contract_id UUID NOT NULL REFERENCES contracts (id) ON DELETE CASCADE, + previous_status contract_status, + new_status contract_status NOT NULL, + changed_by_user_id UUID REFERENCES users (id) ON DELETE SET NULL, + reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_contract_state_logs_contract_id + ON contract_state_logs (contract_id); + +CREATE INDEX IF NOT EXISTS idx_contract_state_logs_created_at + ON contract_state_logs (created_at DESC); + +COMMIT; diff --git a/lib/notifications.ts b/lib/notifications.ts index 9c0777b..8320956 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -17,6 +17,7 @@ export type NotificationType = | "milestone_submitted" | "funds_released" | "contract_created" + | "contract_state_changed" | "dispute_raised" | "escrow_funded" | "escrow_refunded" @@ -246,6 +247,10 @@ const CONTENT_BUILDERS: Record< title: "Contract created", body: `New contract "${str(p, "contractName") ?? "Untitled"}" created.`, }), + contract_state_changed: (p) => ({ + title: "Contract status updated", + body: `Contract "${str(p, "contractName") ?? "Untitled"}" is now ${str(p, "newStatus") ?? "updated"}.`, + }), dispute_raised: (p) => ({ title: "Dispute opened", body: `A dispute has been opened on your contract: ${str(p, "reason") ?? "see the dispute page for details"}.`,