diff --git a/apps/backend/src/migrations/1780262000000-ImplementFourFeatures.ts b/apps/backend/src/migrations/1780262000000-ImplementFourFeatures.ts index d711c428..39c33611 100644 --- a/apps/backend/src/migrations/1780262000000-ImplementFourFeatures.ts +++ b/apps/backend/src/migrations/1780262000000-ImplementFourFeatures.ts @@ -1,18 +1,30 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class ImplementFourFeatures1780262000000 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - // Add new fields to User entity - await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "displayName" varchar(100)`); - await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "email" varchar(255)`); - await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "emailVerified" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "avatarUrl" varchar(500)`); - await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "bio" text`); - await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "preferredAsset" varchar(20) NOT NULL DEFAULT 'XLM'`); - await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_users_email" ON "users" ("email") WHERE "email" IS NOT NULL`); - - // Create EmailVerification table - await queryRunner.query(` + public async up(queryRunner: QueryRunner): Promise { + // Add new fields to User entity + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN "displayName" varchar(100)`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN "email" varchar(255)`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN "emailVerified" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN "avatarUrl" varchar(500)`, + ); + await queryRunner.query(`ALTER TABLE "users" ADD COLUMN "bio" text`); + await queryRunner.query( + `ALTER TABLE "users" ADD COLUMN "preferredAsset" varchar(20) NOT NULL DEFAULT 'XLM'`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX IF NOT EXISTS "IDX_users_email" ON "users" ("email") WHERE "email" IS NOT NULL`, + ); + + // Create EmailVerification table + await queryRunner.query(` CREATE TABLE "email_verifications" ( "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userId" uuid NOT NULL, @@ -24,34 +36,48 @@ export class ImplementFourFeatures1780262000000 implements MigrationInterface { CONSTRAINT "UQ_email_verifications_token" UNIQUE ("token") ) `); - await queryRunner.query(`ALTER TABLE "email_verifications" ADD CONSTRAINT "FK_email_verifications_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE`); - - // Add new fields to Escrow entity - await queryRunner.query(`ALTER TABLE "escrows" ADD COLUMN "releasedAmount" numeric(18,7) NOT NULL DEFAULT 0`); - - // Add new fields to Condition entity - await queryRunner.query(`ALTER TABLE "escrow_conditions" ADD COLUMN "isReleased" boolean NOT NULL DEFAULT false`); - await queryRunner.query(`ALTER TABLE "escrow_conditions" ADD COLUMN "releasedAt" TIMESTAMP`); - } - - public async down(queryRunner: QueryRunner): Promise { - // Drop new columns from Condition entity - await queryRunner.query(`ALTER TABLE "escrow_conditions" DROP COLUMN "releasedAt"`); - await queryRunner.query(`ALTER TABLE "escrow_conditions" DROP COLUMN "isReleased"`); - - // Drop new columns from Escrow entity - await queryRunner.query(`ALTER TABLE "escrows" DROP COLUMN "releasedAmount"`); - - // Drop EmailVerification table - await queryRunner.query(`DROP TABLE "email_verifications"`); - - // Drop new columns from User entity - await queryRunner.query(`DROP INDEX IF EXISTS "IDX_users_email"`); - await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "preferredAsset"`); - await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "bio"`); - await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatarUrl"`); - await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "emailVerified"`); - await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "email"`); - await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "displayName"`); - } + await queryRunner.query( + `ALTER TABLE "email_verifications" ADD CONSTRAINT "FK_email_verifications_userId" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE`, + ); + + // Add new fields to Escrow entity + await queryRunner.query( + `ALTER TABLE "escrows" ADD COLUMN "releasedAmount" numeric(18,7) NOT NULL DEFAULT 0`, + ); + + // Add new fields to Condition entity + await queryRunner.query( + `ALTER TABLE "escrow_conditions" ADD COLUMN "isReleased" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "escrow_conditions" ADD COLUMN "releasedAt" TIMESTAMP`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop new columns from Condition entity + await queryRunner.query( + `ALTER TABLE "escrow_conditions" DROP COLUMN "releasedAt"`, + ); + await queryRunner.query( + `ALTER TABLE "escrow_conditions" DROP COLUMN "isReleased"`, + ); + + // Drop new columns from Escrow entity + await queryRunner.query( + `ALTER TABLE "escrows" DROP COLUMN "releasedAmount"`, + ); + + // Drop EmailVerification table + await queryRunner.query(`DROP TABLE "email_verifications"`); + + // Drop new columns from User entity + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_users_email"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "preferredAsset"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "bio"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "avatarUrl"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "emailVerified"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "email"`); + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "displayName"`); + } } diff --git a/apps/backend/src/migrations/1780300000000-AddRespondedAtToParty.ts b/apps/backend/src/migrations/1780300000000-AddRespondedAtToParty.ts new file mode 100644 index 00000000..5aeb91c7 --- /dev/null +++ b/apps/backend/src/migrations/1780300000000-AddRespondedAtToParty.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRespondedAtToParty1780300000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "escrow_parties" ADD COLUMN "respondedAt" datetime`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "escrow_parties" DROP COLUMN "respondedAt"`, + ); + } +} diff --git a/apps/backend/src/modules/auth/controllers/auth.controller.ts b/apps/backend/src/modules/auth/controllers/auth.controller.ts index 9299a97b..ae5c9c08 100644 --- a/apps/backend/src/modules/auth/controllers/auth.controller.ts +++ b/apps/backend/src/modules/auth/controllers/auth.controller.ts @@ -79,7 +79,10 @@ export class AuthController { @Req() req: Request & { user: { userId: string } }, @Body() updateProfileDto: UpdateProfileDto, ) { - const user = await this.authService.updateProfile(req.user.userId, updateProfileDto); + const user = await this.authService.updateProfile( + req.user.userId, + updateProfileDto, + ); return { id: user.id, walletAddress: user.walletAddress, @@ -109,7 +112,9 @@ export class AuthController { @Post('profile/verify-email') @UseGuards(AuthGuard) @HttpCode(HttpStatus.OK) - async sendEmailVerification(@Req() req: Request & { user: { userId: string } }) { + async sendEmailVerification( + @Req() req: Request & { user: { userId: string } }, + ) { await this.authService.sendEmailVerification(req.user.userId); return { message: 'Verification email sent' }; } diff --git a/apps/backend/src/modules/auth/dto/profile.dto.ts b/apps/backend/src/modules/auth/dto/profile.dto.ts index abce436e..031e5c64 100644 --- a/apps/backend/src/modules/auth/dto/profile.dto.ts +++ b/apps/backend/src/modules/auth/dto/profile.dto.ts @@ -1,4 +1,10 @@ -import { IsBoolean, IsEmail, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsBoolean, + IsEmail, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; export class UpdateProfileDto { @IsOptional() diff --git a/apps/backend/src/modules/auth/services/auth.service.spec.ts b/apps/backend/src/modules/auth/services/auth.service.spec.ts index 1e3da360..104ce86d 100644 --- a/apps/backend/src/modules/auth/services/auth.service.spec.ts +++ b/apps/backend/src/modules/auth/services/auth.service.spec.ts @@ -88,7 +88,9 @@ describe('AuthService', () => { userService = module.get(UserService); jwtService = module.get(JwtService); configService = module.get(ConfigService); - emailVerificationRepository = module.get(getRepositoryToken(EmailVerification)); + emailVerificationRepository = module.get( + getRepositoryToken(EmailVerification), + ); ipfsService = module.get(IpfsService); }); @@ -128,9 +130,9 @@ describe('AuthService', () => { it('should throw UnauthorizedException if user not found', async () => { userService.findByWalletAddress.mockResolvedValue(null); - await expect( - service.verifySignature('sig', 'GD...123'), - ).rejects.toThrow(UnauthorizedException); + await expect(service.verifySignature('sig', 'GD...123')).rejects.toThrow( + UnauthorizedException, + ); }); it('should return tokens on valid signature', async () => { @@ -138,10 +140,7 @@ describe('AuthService', () => { userService.update.mockResolvedValue(mockUser as any); userService.createRefreshToken.mockResolvedValue({} as any); - const result = await service.verifySignature( - 'sig', - 'GD...123', - ); + const result = await service.verifySignature('sig', 'GD...123'); expect(result).toHaveProperty('accessToken'); expect(result).toHaveProperty('refreshToken'); diff --git a/apps/backend/src/modules/auth/services/auth.service.ts b/apps/backend/src/modules/auth/services/auth.service.ts index 5b297f10..313305d2 100644 --- a/apps/backend/src/modules/auth/services/auth.service.ts +++ b/apps/backend/src/modules/auth/services/auth.service.ts @@ -1,4 +1,8 @@ -import { Injectable, UnauthorizedException, BadRequestException } from '@nestjs/common'; +import { + Injectable, + UnauthorizedException, + BadRequestException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as crypto from 'crypto'; @@ -127,7 +131,10 @@ export class AuthService { return user; } - async updateProfile(userId: string, updateProfileDto: UpdateProfileDto): Promise { + async updateProfile( + userId: string, + updateProfileDto: UpdateProfileDto, + ): Promise { const user = await this.userService.findById(userId); if (!user) { throw new UnauthorizedException('User not found'); @@ -141,13 +148,19 @@ export class AuthService { return this.userService.update(userId, updateProfileDto); } - async uploadAvatar(userId: string, file: { buffer: Buffer; originalname: string }): Promise { + async uploadAvatar( + userId: string, + file: { buffer: Buffer; originalname: string }, + ): Promise { const user = await this.userService.findById(userId); if (!user) { throw new UnauthorizedException('User not found'); } - const cid = await this.ipfsService.uploadFile(file.buffer, file.originalname); + const cid = await this.ipfsService.uploadFile( + file.buffer, + file.originalname, + ); const avatarUrl = this.ipfsService.getGatewayUrl(cid); return this.userService.update(userId, { avatarUrl }); diff --git a/apps/backend/src/modules/escrow/controllers/escrow.controller.ts b/apps/backend/src/modules/escrow/controllers/escrow.controller.ts index 0c40d127..b999b8b5 100644 --- a/apps/backend/src/modules/escrow/controllers/escrow.controller.ts +++ b/apps/backend/src/modules/escrow/controllers/escrow.controller.ts @@ -96,6 +96,17 @@ export class EscrowController { return this.escrowService.findOverview(userId, query); } + @Get('pending-invitations') + @ApiOperation({ + summary: + 'List escrows where the authenticated user has a pending party invitation', + }) + async getPendingInvitations(@Request() req: AuthenticatedRequest) { + return this.escrowService.getPendingInvitations( + this.getAuthenticatedUserId(req), + ); + } + @Get(':id') @UseGuards(EscrowAccessGuard) async findOne(@Param('id') id: string) { @@ -269,6 +280,40 @@ export class EscrowController { ); } + @Post(':id/parties/:partyId/accept') + @UseGuards(EscrowAccessGuard) + @ApiOperation({ summary: 'Accept a party invitation for an escrow' }) + async acceptPartyInvitation( + @Param('id') escrowId: string, + @Param('partyId') partyId: string, + @Request() req: AuthenticatedRequest, + ) { + const ipAddress = req.ip || req.socket?.remoteAddress; + return this.escrowService.acceptPartyInvitation( + escrowId, + partyId, + this.getAuthenticatedUserId(req), + ipAddress, + ); + } + + @Post(':id/parties/:partyId/reject') + @UseGuards(EscrowAccessGuard) + @ApiOperation({ summary: 'Reject a party invitation for an escrow' }) + async rejectPartyInvitation( + @Param('id') escrowId: string, + @Param('partyId') partyId: string, + @Request() req: AuthenticatedRequest, + ) { + const ipAddress = req.ip || req.socket?.remoteAddress; + return this.escrowService.rejectPartyInvitation( + escrowId, + partyId, + this.getAuthenticatedUserId(req), + ipAddress, + ); + } + /** * POST /escrows/:id/dispute * File a dispute against an active escrow. Only a buyer or seller party may call this. diff --git a/apps/backend/src/modules/escrow/entities/party.entity.ts b/apps/backend/src/modules/escrow/entities/party.entity.ts index 5b41e3e5..3fc36ad9 100644 --- a/apps/backend/src/modules/escrow/entities/party.entity.ts +++ b/apps/backend/src/modules/escrow/entities/party.entity.ts @@ -54,6 +54,9 @@ export class Party { }) status: PartyStatus; + @Column({ type: 'datetime', nullable: true }) + respondedAt: Date | null; + @CreateDateColumn() createdAt: Date; } diff --git a/apps/backend/src/modules/escrow/escrow.module.ts b/apps/backend/src/modules/escrow/escrow.module.ts index a8105475..d082e454 100644 --- a/apps/backend/src/modules/escrow/escrow.module.ts +++ b/apps/backend/src/modules/escrow/escrow.module.ts @@ -16,6 +16,7 @@ import { AuthModule } from '../auth/auth.module'; import { EscrowStellarIntegrationService } from './services/escrow-stellar-integration.service'; import { WebhookModule } from '../webhook/webhook.module'; import { IpfsModule } from '../ipfs/ipfs.module'; +import { NotificationsModule } from '../../notifications/notifications.module'; import { User } from '../user/entities/user.entity'; import { AllowedAsset } from '../assets/entities/allowed-asset.entity'; import { EscrowLifecycleService } from './escrow-lifecycle.service'; @@ -37,6 +38,7 @@ import { EscrowQueryService } from './escrow-query.service'; AuthModule, WebhookModule, IpfsModule, + NotificationsModule, ], controllers: [EscrowController, EscrowSchedulerController, EventsController], providers: [ diff --git a/apps/backend/src/modules/escrow/services/escrow.service.spec.ts b/apps/backend/src/modules/escrow/services/escrow.service.spec.ts index 3b921df3..093472c4 100644 --- a/apps/backend/src/modules/escrow/services/escrow.service.spec.ts +++ b/apps/backend/src/modules/escrow/services/escrow.service.spec.ts @@ -29,6 +29,8 @@ import { WebhookService } from '../../../services/webhook/webhook.service'; import { IpfsService } from '../../ipfs/ipfs.service'; import { AllowedAsset } from '../../assets/entities/allowed-asset.entity'; import { User, UserRole } from '../../user/entities/user.entity'; +import { NotificationService } from '../../../notifications/notifications.service'; +import { NotificationEventType } from '../../../notifications/enums/notification-event.enum'; // ✅ FIX: missing services import { EscrowLifecycleService } from '../escrow-lifecycle.service'; @@ -49,6 +51,7 @@ describe('EscrowService', () => { let ipfsService: { uploadFile: jest.Mock; getGatewayUrl: jest.Mock }; let webhookService: { dispatchEvent: jest.Mock }; + let notificationService: { handleEscrowEvent: jest.Mock }; // ✅ NEW MOCKS let lifecycleService: { @@ -117,6 +120,8 @@ describe('EscrowService', () => { const mockPartyRepo = { create: jest.fn(), save: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), }; const mockConditionRepo = { @@ -150,6 +155,10 @@ describe('EscrowService', () => { getGatewayUrl: jest.fn().mockReturnValue('https://ipfs.io/ipfs/mock-cid'), }; + const mockNotificationService = { + handleEscrowEvent: jest.fn().mockResolvedValue(undefined), + }; + // ---------------- NEW SERVICE MOCKS ---------------- const mockEscrowLifecycleService = { create: jest.fn(), @@ -182,6 +191,7 @@ describe('EscrowService', () => { { provide: getRepositoryToken(AllowedAsset), useValue: mockAssetRepo }, { provide: IpfsService, useValue: mockIpfsService }, + { provide: NotificationService, useValue: mockNotificationService }, { provide: EscrowStellarIntegrationService, @@ -238,6 +248,7 @@ describe('EscrowService', () => { ipfsService = module.get(IpfsService); webhookService = module.get(WebhookService); + notificationService = module.get(NotificationService); lifecycleService = module.get(EscrowLifecycleService); fundingService = module.get(EscrowFundingService); @@ -249,5 +260,238 @@ describe('EscrowService', () => { expect(service).toBeDefined(); }); - // ✅ KEEP ALL YOUR EXISTING TESTS BELOW UNCHANGED + describe('acceptPartyInvitation', () => { + const pendingParty = { + ...mockParty, + status: PartyStatus.PENDING, + respondedAt: null, + escrow: { + id: 'escrow-123', + title: 'Test Escrow', + status: EscrowStatus.PENDING, + creatorId: 'user-123', + }, + } as Party; + + it('sets status to ACCEPTED and records respondedAt', async () => { + partyRepository.findOne.mockResolvedValue({ ...pendingParty, status: PartyStatus.PENDING }); + partyRepository.save.mockResolvedValue({ + ...pendingParty, + status: PartyStatus.ACCEPTED, + }); + eventRepository.create.mockReturnValue({} as any); + eventRepository.save.mockResolvedValue({} as any); + userRepository.findOne.mockResolvedValue({ + id: 'user-456', + email: 'seller@test.com', + }); + + const result = await service.acceptPartyInvitation( + 'escrow-123', + 'party-123', + 'user-456', + ); + + expect(result.status).toBe(PartyStatus.ACCEPTED); + expect(partyRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: PartyStatus.ACCEPTED, + respondedAt: expect.any(Date), + }), + ); + }); + + it('notifies the escrow creator on acceptance', async () => { + partyRepository.findOne.mockResolvedValue({ ...pendingParty, status: PartyStatus.PENDING }); + partyRepository.save.mockResolvedValue({ + ...pendingParty, + status: PartyStatus.ACCEPTED, + }); + eventRepository.create.mockReturnValue({} as any); + eventRepository.save.mockResolvedValue({} as any); + userRepository.findOne.mockResolvedValue({ + id: 'user-456', + email: 'seller@test.com', + }); + + await service.acceptPartyInvitation( + 'escrow-123', + 'party-123', + 'user-456', + ); + + await new Promise(process.nextTick); // flush fire-and-forget + expect(notificationService.handleEscrowEvent).toHaveBeenCalledWith( + 'user-123', + NotificationEventType.PARTY_ACCEPTED, + expect.objectContaining({ escrowId: 'escrow-123' }), + ); + }); + + it('throws ForbiddenException when user is not the party', async () => { + partyRepository.findOne.mockResolvedValue({ ...pendingParty, status: PartyStatus.PENDING }); + + await expect( + service.acceptPartyInvitation('escrow-123', 'party-123', 'wrong-user'), + ).rejects.toThrow(ForbiddenException); + }); + + it('throws BadRequestException when invitation is already responded', async () => { + partyRepository.findOne.mockResolvedValue({ + ...pendingParty, + status: PartyStatus.ACCEPTED, + }); + + await expect( + service.acceptPartyInvitation('escrow-123', 'party-123', 'user-456'), + ).rejects.toThrow(BadRequestException); + }); + + it('throws NotFoundException when party does not exist', async () => { + partyRepository.findOne.mockResolvedValue(null); + + await expect( + service.acceptPartyInvitation('escrow-123', 'nonexistent', 'user-456'), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('rejectPartyInvitation', () => { + const pendingSellerParty = { + ...mockParty, + status: PartyStatus.PENDING, + respondedAt: null, + escrow: { + id: 'escrow-123', + title: 'Test Escrow', + status: EscrowStatus.PENDING, + creatorId: 'user-123', + }, + } as Party; + + it('sets status to REJECTED and records respondedAt', async () => { + partyRepository.findOne.mockResolvedValue({ ...pendingSellerParty, status: PartyStatus.PENDING }); + partyRepository.save.mockResolvedValue({ + ...pendingSellerParty, + status: PartyStatus.REJECTED, + }); + eventRepository.create.mockReturnValue({} as any); + eventRepository.save.mockResolvedValue({} as any); + escrowRepository.update = jest.fn().mockResolvedValue({}); + userRepository.findOne.mockResolvedValue({ + id: 'user-456', + email: 'seller@test.com', + }); + + const result = await service.rejectPartyInvitation( + 'escrow-123', + 'party-123', + 'user-456', + ); + + expect(result.status).toBe(PartyStatus.REJECTED); + expect(partyRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + status: PartyStatus.REJECTED, + respondedAt: expect.any(Date), + }), + ); + }); + + it('auto-cancels the escrow when a required party rejects', async () => { + partyRepository.findOne.mockResolvedValue({ ...pendingSellerParty, status: PartyStatus.PENDING }); + partyRepository.save.mockResolvedValue({ + ...pendingSellerParty, + status: PartyStatus.REJECTED, + }); + eventRepository.create.mockReturnValue({} as any); + eventRepository.save.mockResolvedValue({} as any); + const updateMock = jest.fn().mockResolvedValue({}); + escrowRepository.update = updateMock; + webhookService.dispatchEvent = jest.fn().mockResolvedValue(undefined); + userRepository.findOne.mockResolvedValue({ id: 'user-456', email: null }); + + await service.rejectPartyInvitation( + 'escrow-123', + 'party-123', + 'user-456', + ); + + expect(updateMock).toHaveBeenCalledWith( + 'escrow-123', + expect.objectContaining({ status: EscrowStatus.CANCELLED }), + ); + }); + + it('throws ForbiddenException when user is not the party', async () => { + partyRepository.findOne.mockResolvedValue({ ...pendingSellerParty, status: PartyStatus.PENDING }); + + await expect( + service.rejectPartyInvitation('escrow-123', 'party-123', 'wrong-user'), + ).rejects.toThrow(ForbiddenException); + }); + }); + + describe('getPendingInvitations', () => { + it('returns pending party invitations for user', async () => { + const pendingParties = [ + { + ...mockParty, + status: PartyStatus.PENDING, + escrow: { id: 'escrow-123' }, + }, + ]; + partyRepository.find.mockResolvedValue(pendingParties); + + const result = await service.getPendingInvitations('user-456'); + + expect(partyRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: { userId: 'user-456', status: PartyStatus.PENDING }, + }), + ); + expect(result).toHaveLength(1); + }); + + it('returns empty array when no pending invitations', async () => { + partyRepository.find.mockResolvedValue([]); + + const result = await service.getPendingInvitations('user-456'); + + expect(result).toHaveLength(0); + }); + }); + + describe('fund - party acceptance gate', () => { + it('throws BadRequestException when seller has not accepted', async () => { + const escrowWithPendingSeller = { + ...mockEscrow, + status: EscrowStatus.PENDING, + stellarTxHash: null, + amount: 100, + parties: [ + { + role: PartyRole.BUYER, + status: PartyStatus.ACCEPTED, + userId: 'user-123', + }, + { + role: PartyRole.SELLER, + status: PartyStatus.PENDING, + userId: 'user-456', + }, + ], + }; + escrowRepository.findOne.mockResolvedValue(escrowWithPendingSeller); + + await expect( + service.fund( + 'escrow-123', + { amount: 100 } as any, + 'user-123', + 'wallet-addr', + ), + ).rejects.toThrow(BadRequestException); + }); + }); }); diff --git a/apps/backend/src/modules/escrow/services/escrow.service.ts b/apps/backend/src/modules/escrow/services/escrow.service.ts index 13774705..bce8d032 100644 --- a/apps/backend/src/modules/escrow/services/escrow.service.ts +++ b/apps/backend/src/modules/escrow/services/escrow.service.ts @@ -9,7 +9,7 @@ import { import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, Repository, SelectQueryBuilder } from 'typeorm'; import { Escrow, EscrowStatus, EscrowType } from '../entities/escrow.entity'; -import { Party, PartyRole } from '../entities/party.entity'; +import { Party, PartyRole, PartyStatus } from '../entities/party.entity'; import { Condition } from '../entities/condition.entity'; import { EscrowEvent, EscrowEventType } from '../entities/escrow-event.entity'; import { @@ -42,6 +42,8 @@ import { WebhookService } from '../../../services/webhook/webhook.service'; import { User, UserRole } from '../../user/entities/user.entity'; import { IpfsService } from '../../ipfs/ipfs.service'; import { AllowedAsset } from '../../assets/entities/allowed-asset.entity'; +import { NotificationService } from '../../../notifications/notifications.service'; +import { NotificationEventType } from '../../../notifications/enums/notification-event.enum'; @Injectable() export class EscrowService { @@ -64,6 +66,7 @@ export class EscrowService { private readonly stellarIntegrationService: EscrowStellarIntegrationService, private readonly webhookService: WebhookService, private readonly ipfsService: IpfsService, + private readonly notificationService: NotificationService, ) {} async create( @@ -96,6 +99,26 @@ export class EscrowService { ); await this.partyRepository.save(parties); + // Notify each invited party (fire-and-forget; failures must not block escrow creation) + for (const partyDto of dto.parties) { + const invitedUser = await this.userRepository.findOne({ + where: { id: partyDto.userId }, + }); + this.notificationService + .handleEscrowEvent( + partyDto.userId, + NotificationEventType.PARTY_INVITED, + { + escrowId: savedEscrow.id, + escrowTitle: savedEscrow.title, + role: partyDto.role, + invitedBy: creatorId, + email: invitedUser?.email ?? undefined, + }, + ) + .catch(() => undefined); + } + if (dto.conditions && dto.conditions.length > 0) { const conditions = dto.conditions.map((conditionDto) => this.conditionRepository.create({ @@ -476,6 +499,17 @@ export class EscrowService { throw new BadRequestException('Escrow is already funded'); } + const unacceptedRequired = (escrow.parties ?? []).filter( + (p) => + (p.role === PartyRole.BUYER || p.role === PartyRole.SELLER) && + p.status !== PartyStatus.ACCEPTED, + ); + if (unacceptedRequired.length > 0) { + throw new BadRequestException( + 'All buyer and seller parties must accept their invitation before the escrow can be funded', + ); + } + const escrowAmount = Number(escrow.amount); if (Number(dto.amount) !== escrowAmount) { throw new BadRequestException('Amount must match the escrow amount'); @@ -1163,6 +1197,143 @@ export class EscrowService { return condition; } + async acceptPartyInvitation( + escrowId: string, + partyId: string, + userId: string, + ipAddress?: string, + ): Promise { + const party = await this.partyRepository.findOne({ + where: { id: partyId, escrowId }, + relations: ['escrow'], + }); + + if (!party) throw new NotFoundException('Party invitation not found'); + if (party.userId !== userId) + throw new ForbiddenException( + 'You can only respond to your own invitation', + ); + if (party.status !== PartyStatus.PENDING) + throw new BadRequestException(`Invitation already ${party.status}`); + + party.status = PartyStatus.ACCEPTED; + party.respondedAt = new Date(); + await this.partyRepository.save(party); + + await this.logEvent( + escrowId, + EscrowEventType.PARTY_ACCEPTED, + userId, + { partyId }, + ipAddress, + ); + + const escrow = party.escrow; + if (escrow?.creatorId) { + const acceptedUser = await this.userRepository.findOne({ + where: { id: userId }, + }); + this.notificationService + .handleEscrowEvent( + escrow.creatorId, + NotificationEventType.PARTY_ACCEPTED, + { + escrowId, + escrowTitle: escrow.title, + role: party.role, + acceptedByUserId: userId, + email: acceptedUser?.email ?? undefined, + }, + ) + .catch(() => undefined); + } + + return party; + } + + async rejectPartyInvitation( + escrowId: string, + partyId: string, + userId: string, + ipAddress?: string, + ): Promise { + const party = await this.partyRepository.findOne({ + where: { id: partyId, escrowId }, + relations: ['escrow'], + }); + + if (!party) throw new NotFoundException('Party invitation not found'); + if (party.userId !== userId) + throw new ForbiddenException( + 'You can only respond to your own invitation', + ); + if (party.status !== PartyStatus.PENDING) + throw new BadRequestException(`Invitation already ${party.status}`); + + party.status = PartyStatus.REJECTED; + party.respondedAt = new Date(); + await this.partyRepository.save(party); + + await this.logEvent( + escrowId, + EscrowEventType.PARTY_REJECTED, + userId, + { partyId }, + ipAddress, + ); + + const escrow = party.escrow; + if (escrow) { + const rejectedUser = await this.userRepository.findOne({ + where: { id: userId }, + }); + + if (escrow.creatorId) { + this.notificationService + .handleEscrowEvent( + escrow.creatorId, + NotificationEventType.PARTY_REJECTED, + { + escrowId, + escrowTitle: escrow.title, + role: party.role, + rejectedByUserId: userId, + email: rejectedUser?.email ?? undefined, + }, + ) + .catch(() => undefined); + } + + // Auto-cancel when a required party (buyer or seller) rejects a PENDING escrow + const isRequired = + party.role === PartyRole.BUYER || party.role === PartyRole.SELLER; + if (isRequired && escrow.status === EscrowStatus.PENDING) { + await this.escrowRepository.update(escrowId, { + status: EscrowStatus.CANCELLED, + }); + await this.logEvent( + escrowId, + EscrowEventType.CANCELLED, + userId, + { reason: `Required party (${party.role}) rejected the invitation` }, + ipAddress, + ); + await this.webhookService.dispatchEvent('escrow.cancelled', { + escrowId, + }); + } + } + + return party; + } + + async getPendingInvitations(userId: string): Promise { + return this.partyRepository.find({ + where: { userId, status: PartyStatus.PENDING }, + relations: ['escrow', 'escrow.parties', 'escrow.creator'], + }); + } + private async logEvent( escrowId: string, eventType: EscrowEventType, @@ -1247,7 +1418,9 @@ export class EscrowService { const escrow = await this.findOne(escrowId); if (escrow.type !== EscrowType.MILESTONE) { - throw new BadRequestException('Only milestone escrows support partial releases'); + throw new BadRequestException( + 'Only milestone escrows support partial releases', + ); } if (escrow.status !== EscrowStatus.ACTIVE) { @@ -1265,7 +1438,9 @@ export class EscrowService { ); if (!isDepositor && !isArbitrator) { - throw new ForbiddenException('Only depositor or arbitrator can release a milestone'); + throw new ForbiddenException( + 'Only depositor or arbitrator can release a milestone', + ); } // Find the condition @@ -1279,7 +1454,9 @@ export class EscrowService { } if (!condition.isMet) { - throw new BadRequestException('Milestone must be confirmed before releasing'); + throw new BadRequestException( + 'Milestone must be confirmed before releasing', + ); } if (!condition.amount) { @@ -1294,7 +1471,8 @@ export class EscrowService { // Calculate released amount const releaseAmount = parseFloat(condition.amount.toString()); - const newReleasedAmount = parseFloat(escrow.releasedAmount.toString()) + releaseAmount; + const newReleasedAmount = + parseFloat(escrow.releasedAmount.toString()) + releaseAmount; // Update escrow escrow.releasedAmount = newReleasedAmount; @@ -1306,8 +1484,10 @@ export class EscrowService { ); // If all are released, set escrow to completed - if (newReleasedAmount >= parseFloat(escrow.amount.toString()) || - newReleasedAmount >= totalMilestonesAmount) { + if ( + newReleasedAmount >= parseFloat(escrow.amount.toString()) || + newReleasedAmount >= totalMilestonesAmount + ) { escrow.status = EscrowStatus.COMPLETED; escrow.isReleased = true; } @@ -1321,12 +1501,10 @@ export class EscrowService { await this.conditionRepository.save(condition); // Log the event - await this.logEvent( - escrowId, - EscrowEventType.MILESTONE_RELEASED, - userId, - { conditionId, amount: releaseAmount }, - ); + await this.logEvent(escrowId, EscrowEventType.MILESTONE_RELEASED, userId, { + conditionId, + amount: releaseAmount, + }); // Dispatch webhook await this.webhookService.dispatchEvent('escrow.milestone_released', { diff --git a/apps/backend/src/modules/escrow/utils/emergency-pause.util.ts b/apps/backend/src/modules/escrow/utils/emergency-pause.util.ts index bd28c57f..2e655a07 100644 --- a/apps/backend/src/modules/escrow/utils/emergency-pause.util.ts +++ b/apps/backend/src/modules/escrow/utils/emergency-pause.util.ts @@ -39,6 +39,8 @@ export function getPauseState(): PauseState { */ export function assertNotPaused(): void { if (paused) { - throw new Error(`System is paused (since ${pausedAt?.toISOString()}, by ${pausedBy})`); + throw new Error( + `System is paused (since ${pausedAt?.toISOString()}, by ${pausedBy})`, + ); } -} \ No newline at end of file +} diff --git a/apps/backend/src/modules/health/health.module.ts b/apps/backend/src/modules/health/health.module.ts index e975317d..e012a6ba 100644 --- a/apps/backend/src/modules/health/health.module.ts +++ b/apps/backend/src/modules/health/health.module.ts @@ -6,10 +6,7 @@ import { User } from '../user/entities/user.entity'; import { Escrow } from '../escrow/entities/escrow.entity'; @Module({ - imports: [ - TerminusModule, - TypeOrmModule.forFeature([User, Escrow]), - ], + imports: [TerminusModule, TypeOrmModule.forFeature([User, Escrow])], controllers: [HealthController], }) export class HealthModule {} diff --git a/apps/backend/src/notifications/enums/notification-event.enum.ts b/apps/backend/src/notifications/enums/notification-event.enum.ts index 02c1de68..3c60ec15 100644 --- a/apps/backend/src/notifications/enums/notification-event.enum.ts +++ b/apps/backend/src/notifications/enums/notification-event.enum.ts @@ -1,4 +1,7 @@ export enum NotificationEventType { + PARTY_INVITED = 'PARTY_INVITED', + PARTY_ACCEPTED = 'PARTY_ACCEPTED', + PARTY_REJECTED = 'PARTY_REJECTED', ESCROW_CREATED = 'ESCROW_CREATED', ESCROW_FUNDED = 'ESCROW_FUNDED', MILESTONE_RELEASED = 'MILESTONE_RELEASED', diff --git a/apps/backend/src/notifications/senders/email.sender.ts b/apps/backend/src/notifications/senders/email.sender.ts index 232c2745..07456daf 100644 --- a/apps/backend/src/notifications/senders/email.sender.ts +++ b/apps/backend/src/notifications/senders/email.sender.ts @@ -100,7 +100,12 @@ export class EmailSender implements NotificationSender { const condition = this.readString(payload, 'condition') ?? 'A condition'; const expiresAt = this.readString(payload, 'expiresAt'); + const role = this.readString(payload, 'role') ?? 'party'; + const subjects: Record = { + [NotificationEventType.PARTY_INVITED]: `You have been invited to escrow: ${escrowTitle}`, + [NotificationEventType.PARTY_ACCEPTED]: `Party accepted invitation for escrow ${escrowId}`, + [NotificationEventType.PARTY_REJECTED]: `Party rejected invitation for escrow ${escrowId}`, [NotificationEventType.ESCROW_CREATED]: `Escrow created: ${escrowTitle} (${escrowId})`, [NotificationEventType.ESCROW_FUNDED]: `Escrow funded: ${escrowTitle} (${escrowId})`, [NotificationEventType.MILESTONE_RELEASED]: `Milestone released for escrow ${escrowId}`, @@ -115,6 +120,12 @@ export class EmailSender implements NotificationSender { }; const textByEvent: Record = { + [NotificationEventType.PARTY_INVITED]: + `You have been invited to participate as ${role} in escrow "${escrowTitle}" (${escrowId}).` + + this.optionalAmount(amount, asset) + + (actionUrl ? `` : ' Log in to accept or reject the invitation.'), + [NotificationEventType.PARTY_ACCEPTED]: `A party has accepted their ${role} invitation for escrow ${escrowId}.`, + [NotificationEventType.PARTY_REJECTED]: `A party has rejected their ${role} invitation for escrow ${escrowId}.`, [NotificationEventType.ESCROW_CREATED]: `A new escrow (${escrowId}) has been created.` + this.optionalAmount(amount, asset),