Skip to content

Commit c10d28e

Browse files
Merge pull request #167 from AdeMi20/Contract-State-Management
feat(contracts): implement centralized contract lifecycle state service
2 parents 0ca02f4 + 66fcf86 commit c10d28e

3 files changed

Lines changed: 185 additions & 0 deletions

File tree

lib/contracts/state-service.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { sql } from "@/lib/db";
2+
import { dispatchNotification } from "@/lib/notifications";
3+
4+
export type ContractStatus =
5+
| 'draft'
6+
| 'pending'
7+
| 'pending_funding'
8+
| 'active'
9+
| 'submitted'
10+
| 'completed'
11+
| 'cancelled'
12+
| 'disputed'
13+
| 'paused';
14+
15+
export interface TransitionStateInput {
16+
contractId: string;
17+
newStatus: ContractStatus;
18+
userId: string;
19+
reason?: string;
20+
contractName?: string;
21+
}
22+
23+
export class ContractStateError extends Error {
24+
constructor(message: string) {
25+
super(message);
26+
this.name = 'ContractStateError';
27+
}
28+
}
29+
30+
// Define valid state transitions
31+
const VALID_TRANSITIONS: Record<ContractStatus, ContractStatus[]> = {
32+
draft: ['pending_funding', 'cancelled'],
33+
pending: ['active', 'cancelled', 'pending_funding'], // Legacy support
34+
pending_funding: ['active', 'cancelled'],
35+
active: ['submitted', 'disputed', 'cancelled', 'completed', 'paused'],
36+
paused: ['active', 'cancelled', 'disputed'],
37+
submitted: ['completed', 'active', 'disputed', 'cancelled'],
38+
disputed: ['completed', 'cancelled', 'active'],
39+
completed: [], // Terminal state
40+
cancelled: [], // Terminal state
41+
};
42+
43+
export class ContractStateService {
44+
/**
45+
* Transitions a contract from its current state to a new state.
46+
* Enforces rules, logs the transition, and broadcasts events.
47+
*
48+
* @throws ContractStateError if the transition is invalid or contract not found
49+
*/
50+
async transitionState(input: TransitionStateInput): Promise<void> {
51+
const { contractId, newStatus, userId, reason } = input;
52+
53+
// Fetch the current status and users
54+
const rows = await sql`
55+
SELECT status, client_id, freelancer_id
56+
FROM contracts
57+
WHERE id = ${contractId}
58+
`;
59+
60+
if (rows.length === 0) {
61+
throw new ContractStateError(`Contract with id ${contractId} not found.`);
62+
}
63+
64+
const contract = rows[0];
65+
const currentStatus = contract.status as ContractStatus;
66+
const clientId = contract.client_id as string;
67+
const freelancerId = contract.freelancer_id as string;
68+
69+
// Validate transition
70+
if (currentStatus === newStatus) {
71+
return; // No-op
72+
}
73+
74+
const allowedTransitions = VALID_TRANSITIONS[currentStatus] || [];
75+
if (!allowedTransitions.includes(newStatus)) {
76+
throw new ContractStateError(
77+
`Invalid state transition from '${currentStatus}' to '${newStatus}'.`
78+
);
79+
}
80+
81+
// Execute updates within a transaction using our SQL template
82+
await sql.begin(async (sqlTransaction) => {
83+
// 1. Update the contract status
84+
await sqlTransaction`
85+
UPDATE contracts
86+
SET status = ${newStatus}::contract_status, updated_at = NOW()
87+
WHERE id = ${contractId}
88+
`;
89+
90+
// 2. Insert the audit log
91+
await sqlTransaction`
92+
INSERT INTO contract_state_logs (
93+
contract_id, previous_status, new_status, changed_by_user_id, reason
94+
) VALUES (
95+
${contractId},
96+
${currentStatus}::contract_status,
97+
${newStatus}::contract_status,
98+
${userId},
99+
${reason || null}
100+
)
101+
`;
102+
});
103+
104+
// 3. Broadcast notifications outside the transaction to avoid rollback issues
105+
const contractName = input.contractName || 'Contract';
106+
const notificationPayload = {
107+
contractId,
108+
contractName,
109+
previousStatus: currentStatus,
110+
newStatus,
111+
reason,
112+
};
113+
114+
// Notify client
115+
if (userId !== clientId) {
116+
await dispatchNotification(clientId, 'contract_state_changed', notificationPayload)
117+
.catch(err => console.error(`Failed to notify client ${clientId}:`, err));
118+
}
119+
120+
// Notify freelancer
121+
if (userId !== freelancerId) {
122+
await dispatchNotification(freelancerId, 'contract_state_changed', notificationPayload)
123+
.catch(err => console.error(`Failed to notify freelancer ${freelancerId}:`, err));
124+
}
125+
}
126+
127+
/**
128+
* Fetches the audit trail for a specific contract.
129+
*/
130+
async getContractStateLogs(contractId: string) {
131+
const rows = await sql`
132+
SELECT
133+
l.id,
134+
l.previous_status,
135+
l.new_status,
136+
l.reason,
137+
l.created_at,
138+
u.display_name as changed_by
139+
FROM contract_state_logs l
140+
LEFT JOIN users u ON l.changed_by_user_id = u.id
141+
WHERE l.contract_id = ${contractId}
142+
ORDER BY l.created_at DESC
143+
`;
144+
return rows;
145+
}
146+
}
147+
148+
export const contractStateService = new ContractStateService();
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
-- Migration 008: Contract Lifecycle States
2+
3+
-- Note: Postgres does not support adding values inside a transaction if the enum
4+
-- is created in the same transaction. However, these ENUMs exist in 001.
5+
-- ALTER TYPE ... ADD VALUE cannot be executed inside a transaction block
6+
-- prior to Postgres 12. TaskChain likely uses modern Postgres, but we'll issue them individually.
7+
8+
COMMIT; -- Ensure we are not in a transaction block if the runner wraps this
9+
10+
ALTER TYPE contract_status ADD VALUE IF NOT EXISTS 'draft';
11+
ALTER TYPE contract_status ADD VALUE IF NOT EXISTS 'pending_funding';
12+
ALTER TYPE contract_status ADD VALUE IF NOT EXISTS 'submitted';
13+
14+
BEGIN;
15+
16+
CREATE TABLE IF NOT EXISTS contract_state_logs (
17+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
18+
contract_id UUID NOT NULL REFERENCES contracts (id) ON DELETE CASCADE,
19+
previous_status contract_status,
20+
new_status contract_status NOT NULL,
21+
changed_by_user_id UUID REFERENCES users (id) ON DELETE SET NULL,
22+
reason TEXT,
23+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
24+
);
25+
26+
CREATE INDEX IF NOT EXISTS idx_contract_state_logs_contract_id
27+
ON contract_state_logs (contract_id);
28+
29+
CREATE INDEX IF NOT EXISTS idx_contract_state_logs_created_at
30+
ON contract_state_logs (created_at DESC);
31+
32+
COMMIT;

lib/notifications.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type NotificationType =
1717
| "milestone_submitted"
1818
| "funds_released"
1919
| "contract_created"
20+
| "contract_state_changed"
2021
| "dispute_raised"
2122
| "escrow_funded"
2223
| "escrow_refunded"
@@ -246,6 +247,10 @@ const CONTENT_BUILDERS: Record<
246247
title: "Contract created",
247248
body: `New contract "${str(p, "contractName") ?? "Untitled"}" created.`,
248249
}),
250+
contract_state_changed: (p) => ({
251+
title: "Contract status updated",
252+
body: `Contract "${str(p, "contractName") ?? "Untitled"}" is now ${str(p, "newStatus") ?? "updated"}.`,
253+
}),
249254
dispute_raised: (p) => ({
250255
title: "Dispute opened",
251256
body: `A dispute has been opened on your contract: ${str(p, "reason") ?? "see the dispute page for details"}.`,

0 commit comments

Comments
 (0)