Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 148 additions & 0 deletions lib/contracts/state-service.ts
Original file line number Diff line number Diff line change
@@ -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<ContractStatus, ContractStatus[]> = {
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<void> {
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();
32 changes: 32 additions & 0 deletions lib/db/migrations/008_contract_lifecycle_states.sql
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 5 additions & 0 deletions lib/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type NotificationType =
| "milestone_submitted"
| "funds_released"
| "contract_created"
| "contract_state_changed"
| "dispute_raised"
| "escrow_funded"
| "escrow_refunded"
Expand Down Expand Up @@ -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"}.`,
Expand Down
Loading