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
55 changes: 55 additions & 0 deletions src/agreements/agreement-activity.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { AgreementActivityService } from './agreement-activity.service';

describe('AgreementActivityService', () => {
it('inserts activity rows via supabase including optional state columns', async () => {
const insert = jest.fn().mockResolvedValue({ error: null });
const from = jest.fn().mockReturnValue({ insert });
const supabase = { getClient: () => ({ from }) } as never;

const svc = new AgreementActivityService(supabase);
await svc.logActivity(
'agr-1',
'GWALLET',
'dispute_opened',
{ dispute_id: 'd1' },
{ previousState: 'active', newState: 'disputed' },
);

expect(from).toHaveBeenCalledWith('agreement_activity');
expect(insert).toHaveBeenCalledWith({
agreement_id: 'agr-1',
actor_wallet: 'GWALLET',
action: 'dispute_opened',
details: { dispute_id: 'd1' },
previous_state: 'active',
new_state: 'disputed',
});
});

it('defaults previous_state/new_state to null when omitted', async () => {
const insert = jest.fn().mockResolvedValue({ error: null });
const from = jest.fn().mockReturnValue({ insert });
const supabase = { getClient: () => ({ from }) } as never;

const svc = new AgreementActivityService(supabase);
await svc.logActivity('agr-1', 'G', 'created');

expect(insert).toHaveBeenCalledWith({
agreement_id: 'agr-1',
actor_wallet: 'G',
action: 'created',
details: {},
previous_state: null,
new_state: null,
});
});

it('swallows insert errors without throwing', async () => {
const insert = jest.fn().mockResolvedValue({ error: { message: 'boom' } });
const from = jest.fn().mockReturnValue({ insert });
const supabase = { getClient: () => ({ from }) } as never;

const svc = new AgreementActivityService(supabase);
await expect(svc.logActivity('agr-1', 'G', 'created')).resolves.toBeUndefined();
});
});
48 changes: 48 additions & 0 deletions src/agreements/agreement-activity.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { Injectable, Logger } from '@nestjs/common';
import { SupabaseService } from '../supabase/supabase.service';

export type ActivityStates = {
previousState?: string | null;
newState?: string | null;
};

/**
* Single shared writer for `agreement_activity` rows.
* All services must use this instead of private logActivity copies.
*
* Supports optional `previous_state` / `new_state` columns introduced by the
* activity-state logging work so status transitions stay queryable.
*/
@Injectable()
export class AgreementActivityService {
private readonly logger = new Logger(AgreementActivityService.name);

constructor(private readonly supabase: SupabaseService) {}

async logActivity(
agreementId: string,
actorWallet: string,
action: string,
details: Record<string, unknown> = {},
states: ActivityStates = {},
): Promise<void> {
try {
const { error } = await this.supabase
.getClient()
.from('agreement_activity')
.insert({
agreement_id: agreementId,
actor_wallet: actorWallet,
action,
details,
previous_state: states.previousState ?? null,
new_state: states.newState ?? null,
});
if (error) {
this.logger.error(`logActivity insert failed: ${error.message}`);
}
} catch (e) {
this.logger.error('logActivity', e);
}
}
}
28 changes: 23 additions & 5 deletions src/agreements/agreement-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';
import { AgreementsService } from './agreements.service';
import { AgreementActivityService } from './agreement-activity.service';
import { DisputesService } from '../disputes/disputes.service';
import { UpdateAgreementStatusDto } from './dto/update-status.dto';
import type { SupabaseService } from '../supabase/supabase.service';
Expand All @@ -37,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 @@ -54,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 @@ -68,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 @@ -392,9 +393,11 @@
beforeEach(() => {
db = new InMemoryDb();
emit = jest.fn();
const activity = new AgreementActivityService(db as unknown as SupabaseService);
service = new AgreementsService(
db as unknown as SupabaseService,
{ emit } as unknown as EventEmitter2,
activity,
);
});

Expand Down Expand Up @@ -768,8 +771,9 @@
db = new InMemoryDb();
emit = jest.fn();
const emitter = { emit } as unknown as EventEmitter2;
agreements = new AgreementsService(db as unknown as SupabaseService, emitter);
disputes = new DisputesService(db as unknown as SupabaseService, agreements, emitter);
const activity = new AgreementActivityService(db as unknown as SupabaseService);
agreements = new AgreementsService(db as unknown as SupabaseService, emitter, activity);
disputes = new DisputesService(db as unknown as SupabaseService, agreements, emitter, activity);

db.insert('agreements', {
id: AGREEMENT_ID,
Expand Down Expand Up @@ -809,7 +813,17 @@
const disputeId = await openDispute();

expect(db.agreement(AGREEMENT_ID).status).toBe('disputed');
// Shared side-effect path logs status_changed_to_* then dispute-specific action
expect(db.activityFor(AGREEMENT_ID)).toEqual([
expect.objectContaining({
action: 'status_changed_to_disputed',
details: expect.objectContaining({
status: 'disputed',
from: 'active',
to: 'disputed',
dispute_id: disputeId,
}),
}),
expect.objectContaining({
action: 'dispute_opened',
details: expect.objectContaining({ dispute_id: disputeId }),
Expand All @@ -828,20 +842,22 @@
});

const timeline = db.activityFor(AGREEMENT_ID);
// Dispute lifecycle events live in the SAME agreement timeline (deduped logger).
// Shared side-effect path also writes status_changed_to_* around dispute actions.
expect(timeline.map((a: Row) => a.action)).toEqual([
'status_changed_to_disputed',
'dispute_opened',
'dispute_resolver_assigned',
'status_changed_to_resolved',
'dispute_resolved',
]);
expect(timeline[0]).toEqual(
expect(timeline.find((a: Row) => a.action === 'dispute_opened')).toEqual(
expect.objectContaining({
action: 'dispute_opened',
previous_state: 'active',
new_state: 'disputed',
}),
);
expect(timeline[2]).toEqual(
expect(timeline.find((a: Row) => a.action === 'dispute_resolved')).toEqual(
expect.objectContaining({
action: 'dispute_resolved',
previous_state: 'disputed',
Expand Down Expand Up @@ -877,8 +893,10 @@
expect(db.agreement(AGREEMENT_ID).status).toBe('resolved');
expect(db.agreement(AGREEMENT_ID).completed_at).toBeDefined();
expect(db.activityFor(AGREEMENT_ID).map((a) => a.action)).toEqual([
'status_changed_to_disputed',
'dispute_opened',
'dispute_resolver_assigned',
'status_changed_to_resolved',
'dispute_resolved',
]);

Expand Down
5 changes: 3 additions & 2 deletions src/agreements/agreements.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { AgreementsController } from './agreements.controller';
import { AgreementsService } from './agreements.service';
import { AgreementActivityService } from './agreement-activity.service';

@Module({
imports: [AuthModule],
controllers: [AgreementsController],
providers: [AgreementsService],
exports: [AgreementsService],
providers: [AgreementsService, AgreementActivityService],
exports: [AgreementsService, AgreementActivityService],
})
export class AgreementsModule {}
Loading
Loading