Skip to content
27 changes: 27 additions & 0 deletions scripts/005_add_agreement_chat.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- Migration: Add sender_id to agreement_messages for chat functionality
-- Adds nullable sender_id column to track message authors

-- Add sender_id column to agreement_messages if it doesn't already exist
-- Using NULL default to allow backfill of existing rows
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'agreement_messages'
AND column_name = 'sender_id'
) THEN
ALTER TABLE public.agreement_messages ADD COLUMN sender_id UUID NULL REFERENCES public.auth_users(id) ON DELETE CASCADE;
END IF;
END $$;

-- Create index on sender_id for query performance (only if column was just added)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.statistics
WHERE table_schema = 'public'
AND table_name = 'agreement_messages'
AND index_name = 'idx_agreement_messages_sender_id'
) THEN
CREATE INDEX idx_agreement_messages_sender_id ON public.agreement_messages(sender_id);
END IF;
END $$;
4 changes: 2 additions & 2 deletions src/agreement-chat/agreement-chat.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ export class AgreementChatController {
constructor(private readonly chat: AgreementChatService) {}

@Get(':agreementId/messages')
getMessages(@CurrentUser() user: AuthUserCtx, @Param('agreementId') agreementId: string) {
async getMessages(@CurrentUser() user: AuthUserCtx, @Param('agreementId') agreementId: string) {
return this.chat.getMessages(user.userId, agreementId);
}

@Post(':agreementId/messages')
sendMessage(
async sendMessage(
@CurrentUser() user: AuthUserCtx,
@Param('agreementId') agreementId: string,
@Body() dto: Omit<SendMessageDto, 'agreement_id'>,
Expand Down
18 changes: 13 additions & 5 deletions src/agreement-chat/agreement-chat.service.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SupabaseService } from '../supabase/supabase.service';
import { SendMessageDto } from './dto/agreement-chat.dto';

Expand Down Expand Up @@ -48,13 +53,16 @@
const createdBy = (agreement as { created_by: string }).created_by;
if (createdBy === wallet || createdBy === userId) return;

const { data: parts } = await this.supabase
const { data: parts, error: partErr } = await this.supabase
.getClient()
.from('agreement_participants')
.select('wallet_address')
.eq('agreement_id', agreementId)
.eq('wallet_address', wallet)
.limit(1);
if (partErr) {
throw new BadRequestException(`Failed to verify participant status: ${partErr.message}`);
}
if (!parts?.length) {
throw new ForbiddenException('Not a participant of this agreement');
}
Expand All @@ -71,7 +79,7 @@
.order('created_at', { ascending: true });

if (error) {
return { messages: [], error: error.message };
throw new BadRequestException(`Failed to retrieve messages: ${error.message}`);
}

return { messages: (data as AgreementMessage[]) || [], error: null };
Expand All @@ -82,10 +90,10 @@
await this.assertActorWallet(userId, dto.sender_wallet);

if (!dto.message.trim()) {
return { message: null, error: 'Message cannot be empty' };
throw new BadRequestException('Message cannot be empty');
}

const { data, error } = await this.supabase

Check warning on line 96 in src/agreement-chat/agreement-chat.service.ts

View workflow job for this annotation

GitHub Actions / Lint

Unsafe object destructuring of a property with an `any` value
.getClient()
.from('agreement_messages')
.insert({
Expand All @@ -98,7 +106,7 @@
.single();

if (error) {
return { message: null, error: error.message };
throw new BadRequestException(`Failed to send message: ${error.message}`);
}

return { message: data as AgreementMessage, error: null };
Expand Down
5 changes: 4 additions & 1 deletion src/agreements/agreements.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ export class AgreementsService {
.insert(participants);

if (participantsError) {
console.error('agreement_participants insert:', participantsError);
await this.supabase.getClient().from('agreements').delete().eq('id', agreement.id);
throw new BadRequestException(
`Failed to create agreement participants: ${participantsError.message}`,
);
}

await this.activity.logActivity(agreement.id, dto.created_by, 'created', {
Expand Down
227 changes: 227 additions & 0 deletions src/integration/migrated-flows.integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ class InMemorySupabase {
},
];
this.tables.agreement_activity = [];
this.tables.agreement_messages = [];
this.tables.disputes = [];
this.tables.dispute_resolutions = [];
}
Expand Down Expand Up @@ -651,6 +652,232 @@ describe('migrated backend flows (integration)', () => {
expect(offenders).toEqual([]);
});

it('enforces participant creation on agreement create and rejects if it fails', async () => {
let createdAgreementId: string;

await request(app.getHttpServer())
.post('/v1/agreements')
.set(auth())
.send({
title: 'Chat test agreement',
amount: '100.00',
created_by: WALLET,
participants: [
{ wallet_address: WALLET, role: 'payer' },
{ wallet_address: OTHER_WALLET, role: 'payee' },
],
})
.expect(201)
.expect(({ body }) => {
expect(body.error).toBeNull();
expect(body.agreement).toBeDefined();
createdAgreementId = body.agreement.id;
});

// Verify participants were created
const participants = supabase.tables.agreement_participants.filter(
(p) => p.agreement_id === createdAgreementId,
);
expect(participants).toHaveLength(2);
});

it('rolls back agreement creation when participant insert fails', async () => {
const initialAgreementCount = supabase.tables.agreements.length;

// Force participant insert to fail
supabase.failOnce('agreement_participants', 'insert', 'participant constraint violation');

await request(app.getHttpServer())
.post('/v1/agreements')
.set(auth())
.send({
title: 'Failed participant agreement',
amount: '100.00',
created_by: WALLET,
participants: [
{ wallet_address: WALLET, role: 'payer' },
{ wallet_address: OTHER_WALLET, role: 'payee' },
],
})
.expect(400)
.expect(({ body }) => {
expect(body.message || body.error).toContain('Failed to create agreement participants');
});

expect(supabase.tables.agreements).toHaveLength(initialAgreementCount);
});

it('allows agreement creator and participants to list messages', async () => {
// Creator (USER_ID with WALLET) can list messages
await request(app.getHttpServer())
.get(`/v1/agreements/${AGREEMENT_ID}/messages`)
.set(auth())
.expect(200)
.expect(({ body }) => {
expect(body.messages).toEqual([]);
expect(body.error).toBeNull();
});

// Add a participant owned by OTHER_USER_ID to verify role-based access
supabase.tables.agreement_participants.push({
id: 'participant-test-other-user',
agreement_id: AGREEMENT_ID,
wallet_address: OTHER_WALLET,
role: 'payee',
});

// Participant (OTHER_USER_ID with OTHER_WALLET) can also list messages
await request(app.getHttpServer())
.get(`/v1/agreements/${AGREEMENT_ID}/messages`)
.set(auth(OTHER_USER_ID))
.expect(200)
.expect(({ body }) => {
expect(body.messages).toEqual([]);
expect(body.error).toBeNull();
});
});

it('rejects non-participants from listing messages', async () => {
await request(app.getHttpServer())
.get(`/v1/agreements/${AGREEMENT_ID}/messages`)
.set(auth(RESOLVER_USER_ID))
.expect(403);
});

it('creator can send a message with sender_id and participants receive it', async () => {
// Create a new agreement for this test with proper participants
const createRes = await request(app.getHttpServer())
.post('/v1/agreements')
.set(auth())
.send({
title: 'Chat test - creator sends',
amount: '100.00',
created_by: WALLET,
participants: [
{ wallet_address: WALLET, role: 'payer' },
{ wallet_address: OTHER_WALLET, role: 'payee' },
],
});
expect(createRes.status).toBe(201);
const testAgreementId = createRes.body.agreement.id;

const messageText = `Test message from creator at ${Date.now()}`;
let messageId: string;

// Creator sends message
await request(app.getHttpServer())
.post(`/v1/agreements/${testAgreementId}/messages`)
.set(auth())
.send({
sender_wallet: WALLET,
message: messageText,
})
.expect(201)
.expect(({ body }) => {
expect(body.error).toBeNull();
expect(body.message).toBeDefined();
expect(body.message.sender_id).toBe(USER_ID);
expect(body.message.sender_wallet).toBe(WALLET);
expect(body.message.message).toBe(messageText);
messageId = body.message.id;
});

// Participant (OTHER_USER_ID) can retrieve the message
await request(app.getHttpServer())
.get(`/v1/agreements/${testAgreementId}/messages`)
.set(auth(OTHER_USER_ID))
.expect(200)
.expect(({ body }) => {
expect(body.messages).toHaveLength(1);
expect(body.messages[0].id).toBe(messageId);
expect(body.messages[0].sender_id).toBe(USER_ID);
expect(body.messages[0].message).toBe(messageText);
});
});

it('participant can send a message and creator receives it', async () => {
// Create a new agreement for this test
const createRes = await request(app.getHttpServer())
.post('/v1/agreements')
.set(auth())
.send({
title: 'Chat test - participant sends',
amount: '100.00',
created_by: WALLET,
participants: [
{ wallet_address: WALLET, role: 'payer' },
{ wallet_address: OTHER_WALLET, role: 'payee' },
],
});
expect(createRes.status).toBe(201);
const testAgreementId = createRes.body.agreement.id;

const messageText = `Test message from participant at ${Date.now()}`;
let messageId: string;

// Participant sends message
await request(app.getHttpServer())
.post(`/v1/agreements/${testAgreementId}/messages`)
.set(auth(OTHER_USER_ID))
.send({
sender_wallet: OTHER_WALLET,
message: messageText,
})
.expect(201)
.expect(({ body }) => {
expect(body.error).toBeNull();
expect(body.message).toBeDefined();
expect(body.message.sender_id).toBe(OTHER_USER_ID);
expect(body.message.sender_wallet).toBe(OTHER_WALLET);
messageId = body.message.id;
});

// Creator can retrieve the message
await request(app.getHttpServer())
.get(`/v1/agreements/${testAgreementId}/messages`)
.set(auth())
.expect(200)
.expect(({ body }) => {
const found = body.messages.find((m: any) => m.id === messageId);
expect(found).toBeDefined();
expect(found.sender_id).toBe(OTHER_USER_ID);
expect(found.message).toBe(messageText);
});
});

it('rejects empty messages', async () => {
await request(app.getHttpServer())
.post(`/v1/agreements/${AGREEMENT_ID}/messages`)
.set(auth())
.send({
sender_wallet: WALLET,
message: ' ',
})
.expect(400);
});

it('rejects messages from non-participants', async () => {
await request(app.getHttpServer())
.post(`/v1/agreements/${AGREEMENT_ID}/messages`)
.set(auth(RESOLVER_USER_ID))
.send({
sender_wallet: RESOLVER_WALLET,
message: 'Unauthorized message',
})
.expect(403);
});

it('rejects messages when sender_wallet does not match authenticated user', async () => {
await request(app.getHttpServer())
.post(`/v1/agreements/${AGREEMENT_ID}/messages`)
.set(auth())
.send({
sender_wallet: OTHER_WALLET,
message: 'Impersonation attempt',
})
.expect(403);
});

async function openAndAssignDispute() {
disputeAgreementSequence += 1;
const agreementId = `550e8400-e29b-41d4-a716-44665544010${disputeAgreementSequence}`;
Expand Down
Loading