diff --git a/scripts/005_add_agreement_chat.sql b/scripts/005_add_agreement_chat.sql new file mode 100644 index 0000000..81eeaab --- /dev/null +++ b/scripts/005_add_agreement_chat.sql @@ -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 $$; diff --git a/src/agreement-chat/agreement-chat.controller.ts b/src/agreement-chat/agreement-chat.controller.ts index 3995d28..6fe3b5a 100644 --- a/src/agreement-chat/agreement-chat.controller.ts +++ b/src/agreement-chat/agreement-chat.controller.ts @@ -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, diff --git a/src/agreement-chat/agreement-chat.service.ts b/src/agreement-chat/agreement-chat.service.ts index de04d45..9d761c5 100644 --- a/src/agreement-chat/agreement-chat.service.ts +++ b/src/agreement-chat/agreement-chat.service.ts @@ -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'; @@ -48,13 +53,16 @@ export class AgreementChatService { 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'); } @@ -71,7 +79,7 @@ export class AgreementChatService { .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 }; @@ -82,7 +90,7 @@ export class AgreementChatService { 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 @@ -98,7 +106,7 @@ export class AgreementChatService { .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 }; diff --git a/src/agreements/agreements.service.ts b/src/agreements/agreements.service.ts index 856ac5c..05493dc 100644 --- a/src/agreements/agreements.service.ts +++ b/src/agreements/agreements.service.ts @@ -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', { diff --git a/src/integration/migrated-flows.integration.spec.ts b/src/integration/migrated-flows.integration.spec.ts index 7c8805f..b6e71b1 100644 --- a/src/integration/migrated-flows.integration.spec.ts +++ b/src/integration/migrated-flows.integration.spec.ts @@ -236,6 +236,7 @@ class InMemorySupabase { }, ]; this.tables.agreement_activity = []; + this.tables.agreement_messages = []; this.tables.disputes = []; this.tables.dispute_resolutions = []; } @@ -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}`;