Skip to content
Open
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
75 changes: 75 additions & 0 deletions src/agreements/agreement-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,9 @@
milestonesSatisfyCompletion,
} from './agreement-lifecycle';

type Row = Record<string, any>;

Check warning on line 41 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
type Filter = { key: string; op: 'eq' | 'neq' | 'in'; value: any };

Check warning on line 42 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
type QueryResult = { data: any; error: { message: string; code?: string } | null };

Check warning on line 43 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type

const PAYER_USER = 'lifecycle-user-payer';
const PAYEE_USER = 'lifecycle-user-payee';
Expand All @@ -55,7 +55,7 @@
class QueryBuilder implements PromiseLike<QueryResult> {
private filters: Filter[] = [];
private mode: 'select' | 'insert' | 'update' = 'select';
private payload: any;

Check warning on line 58 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
private resultMode: 'many' | 'single' | 'maybeSingle' = 'many';
private orderBy: { key: string; ascending: boolean } | undefined;
private rowLimit: number | undefined;
Expand All @@ -69,17 +69,17 @@
return this;
}

eq(key: string, value: any) {

Check warning on line 72 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
this.filters.push({ key, op: 'eq', value });

Check warning on line 73 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
return this;
}

neq(key: string, value: any) {

Check warning on line 77 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
this.filters.push({ key, op: 'neq', value });

Check warning on line 78 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe assignment of an `any` value
return this;
}

in(key: string, value: any[]) {

Check warning on line 82 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected any. Specify a different type
this.filters.push({ key, op: 'in', value });
return this;
}
Expand Down Expand Up @@ -390,15 +390,78 @@
{ description: 'Build', amount: '50.00', status: 'released' },
];

const backendClient = {
getAgreement: jest.fn(),
createAgreement: jest.fn(),
linkContract: jest.fn(),
updateAgreementStatus: jest.fn(),
updateMilestone: jest.fn(),
listAgreementsByWallet: jest.fn(),
getAgreementByContractId: jest.fn(),
getAgreementActivity: jest.fn(),
logActivity: jest.fn(),
};

beforeEach(() => {
db = new InMemoryDb();
emit = jest.fn();
const activity = new AgreementActivityService(db as unknown as SupabaseService);
service = new AgreementsService(
db as unknown as SupabaseService,
backendClient as unknown as AgreementsBackendClient,
{ emit } as unknown as EventEmitter2,
activity,
);

// Reset and setup backend client mocks
backendClient.getAgreement.mockReset();
backendClient.updateAgreementStatus.mockReset();
backendClient.logActivity.mockReset();

// Make getAgreement return the agreement from the in-memory DB
backendClient.getAgreement.mockImplementation((agreementId) => {
try {
const agreement = db.agreement(agreementId);
const participants = db.tables.agreement_participants.filter(
(p) => p.agreement_id === agreementId,
);
return Promise.resolve({
success: true,
data: { agreement, participants },
});
} catch {
return Promise.resolve({
success: false,
error: 'Agreement not found',
});
}
});

// Make updateAgreementStatus actually update the DB
backendClient.updateAgreementStatus.mockImplementation((agreementId, req) => {
const updates: Record<string, unknown> = {
status: req.status,
updated_at: new Date().toISOString(),
};
if (req.status === 'funded') {
updates.funded_at = new Date().toISOString();
} else if (req.status === 'completed' || req.status === 'resolved') {
updates.completed_at = new Date().toISOString();
}
db.update('agreements', [{ key: 'id', op: 'eq', value: agreementId }], updates);
return Promise.resolve({ success: true });
});

// Make logActivity actually insert into the activity table
backendClient.logActivity.mockImplementation((req) => {
db.insert('agreement_activity', {
agreement_id: req.agreement_id,
actor_wallet: req.actor_wallet,
action: req.action,
details: req.details || {},
});
return Promise.resolve({ success: true });
});
});

function seedAgreement(status: AgreementStatus, overrides: Row = {}): string {
Expand Down Expand Up @@ -767,6 +830,18 @@
let disputes: DisputesService;
const AGREEMENT_ID = 'agr-dispute-1';

const backendClient = {

Check failure on line 833 in src/agreements/agreement-lifecycle.spec.ts

View workflow job for this annotation

GitHub Actions / Lint

'backendClient' is assigned a value but never used. Allowed unused vars must match /^_/u
getAgreement: jest.fn(),
createAgreement: jest.fn(),
linkContract: jest.fn(),
updateAgreementStatus: jest.fn(),
updateMilestone: jest.fn(),
listAgreementsByWallet: jest.fn(),
getAgreementByContractId: jest.fn(),
getAgreementActivity: jest.fn(),
logActivity: jest.fn(),
};

beforeEach(() => {
db = new InMemoryDb();
emit = jest.fn();
Expand Down
266 changes: 266 additions & 0 deletions src/agreements/agreements-backend.client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
import { Injectable, Logger } from '@nestjs/common';
import { ApiClient, ApiResponse } from '../common/api/api-client';

export interface Agreement {
id: string;
contract_id: string | null;
title: string;
description: string | null;
amount: string;
asset: string;
status: string;
agreement_type: string;
milestones: Array<{
description: string;
amount: string;
status: string;
evidence_description?: string;
evidence_urls?: string[];
evidence_submitted_at?: string;
}>;
metadata: Record<string, unknown>;
created_by: string;
created_by_profile_id?: string | null;
funded_at: string | null;
completed_at: string | null;
created_at: string;
updated_at: string;
}

/**
* Agreement participant data structure.
*/
export interface AgreementParticipant {
id: string;
agreement_id: string;
wallet_address: string;
role: string;
profile_id?: string | null;
created_at: string;
updated_at: string;
}

export interface AgreementActivity {
id: string;
agreement_id: string;
actor_wallet: string;
action: string;
details: Record<string, unknown>;
created_at: string;
}

export interface CreateAgreementRequest {
contract_id?: string | null;
title: string;
description?: string | null;
amount: string;
asset?: string;
agreement_type?: string;
milestones?: Array<{
description: string;
amount: string;
status: string;
}>;
metadata?: Record<string, unknown>;
created_by: string;
created_by_profile_id?: string | null;
participants: Array<{
wallet_address: string;
role: string;
profile_id?: string | null;
}>;
}

export interface CreateAgreementResponse {
agreement: Agreement;
}

export interface LinkContractResponse {
success: boolean;
agreement?: Agreement;
}

export interface UpdateAgreementStatusRequest {
status: string;
actor_wallet: string;
}

export interface UpdateAgreementStatusResponse {
success: boolean;
agreement?: Agreement;
}

export interface UpdateMilestoneRequest {
milestone_index: number;
status: string;
actor_wallet: string;
evidence_description?: string;
evidence_urls?: string[];
}

export interface UpdateMilestoneResponse {
success: boolean;
agreement?: Agreement;
}

export interface LogActivityRequest {
agreement_id: string;
actor_wallet: string;
action: string;
details?: Record<string, unknown>;
}

export interface GetByContractResponse {
agreement: Agreement | null;
}

export interface GetActivityResponse {
activities: AgreementActivity[];
}

export interface GetAgreementResponse {
agreement: Agreement | null;
participants: AgreementParticipant[];
}

export interface ListAgreementsResponse {
agreements: Agreement[];
}

@Injectable()
export class AgreementsBackendClient {
private readonly logger = new Logger(AgreementsBackendClient.name);
private readonly baseUrl: string;

constructor(private readonly apiClient: ApiClient) {
// For now, we assume backend endpoints are on the same server
// In the future, this could be configurable via environment variable
this.baseUrl = 'http://localhost:3001';
}

async createAgreement(
walletAddress: string,
req: CreateAgreementRequest,
): Promise<ApiResponse<CreateAgreementResponse>> {
return this.apiClient.post<CreateAgreementResponse>(`${this.baseUrl}/agreements`, req, {
headers: {
'X-Wallet-Address': walletAddress,
},
});
}

async getAgreement(
agreementId: string,
walletAddress: string,
): Promise<ApiResponse<GetAgreementResponse>> {
return this.apiClient.get<GetAgreementResponse>(`${this.baseUrl}/agreements/${agreementId}`, {
headers: {
'X-Wallet-Address': walletAddress,
},
});
}

async getAgreementByContractId(
contractId: string,
walletAddress: string,
): Promise<ApiResponse<GetByContractResponse>> {
return this.apiClient.get<GetByContractResponse>(
`${this.baseUrl}/agreements/by-contract/${contractId}`,
{
headers: {
'X-Wallet-Address': walletAddress,
},
},
);
}

async listAgreementsByWallet(
wallet: string,
walletAddress: string,
): Promise<ApiResponse<ListAgreementsResponse>> {
return this.apiClient.get<ListAgreementsResponse>(`${this.baseUrl}/agreements`, {
query: { wallet },
headers: {
'X-Wallet-Address': walletAddress,
},
});
}

async updateAgreementStatus(
agreementId: string,
req: UpdateAgreementStatusRequest,
walletAddress: string,
): Promise<ApiResponse<UpdateAgreementStatusResponse>> {
return this.apiClient.patch<UpdateAgreementStatusResponse>(
`${this.baseUrl}/agreements/${agreementId}/status`,
req,
{
headers: {
'X-Wallet-Address': walletAddress,
},
},
);
}

async updateMilestone(
agreementId: string,
req: UpdateMilestoneRequest,
walletAddress: string,
): Promise<ApiResponse<UpdateMilestoneResponse>> {
return this.apiClient.patch<UpdateMilestoneResponse>(
`${this.baseUrl}/agreements/${agreementId}/milestones`,
req,
{
headers: {
'X-Wallet-Address': walletAddress,
},
},
);
}

async getAgreementActivity(
agreementId: string,
walletAddress: string,
): Promise<ApiResponse<GetActivityResponse>> {
return this.apiClient.get<GetActivityResponse>(
`${this.baseUrl}/agreements/${agreementId}/activity`,
{
headers: {
'X-Wallet-Address': walletAddress,
},
},
);
}

async logActivity(
req: LogActivityRequest,
walletAddress: string,
): Promise<ApiResponse<{ success: boolean }>> {
return this.apiClient.post<{ success: boolean }>(
`${this.baseUrl}/agreements/${req.agreement_id}/activity`,
req,
{
headers: {
'X-Wallet-Address': walletAddress,
},
},
);
}

async linkContract(
agreementId: string,
contractId: string,
actorWallet: string,
walletAddress: string,
): Promise<ApiResponse<LinkContractResponse>> {
return this.apiClient.patch<LinkContractResponse>(
`${this.baseUrl}/agreements/${agreementId}/link-contract`,
{ contract_id: contractId, actor_wallet: actorWallet },
{
headers: {
'X-Wallet-Address': walletAddress,
},
},
);
}
}
3 changes: 2 additions & 1 deletion src/agreements/agreements.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ApiClientModule } from '../common/api/api-client.module';
import { AgreementsController } from './agreements.controller';
import { AgreementsService } from './agreements.service';
import { AgreementActivityService } from './agreement-activity.service';

@Module({
imports: [AuthModule],
imports: [AuthModule, ApiClientModule],
controllers: [AgreementsController],
providers: [AgreementsService, AgreementActivityService],
exports: [AgreementsService, AgreementActivityService],
Expand Down
Loading
Loading