diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index 1d46e0c..1cf34fd 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -16,10 +16,12 @@ "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.17", "@stellar/stellar-sdk": "^14.5.0", "@types/bcrypt": "^6.0.0", "@types/passport-jwt": "^4.0.1", @@ -32,6 +34,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "sqlite3": "^5.1.7", "stellar-sdk": "^13.3.0", "swagger-ui-express": "^5.0.1", @@ -2303,6 +2306,25 @@ "@nestjs/core": "^11.0.0" } }, + "node_modules/@nestjs/platform-socket.io": { + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/platform-socket.io/-/platform-socket.io-11.1.17.tgz", + "integrity": "sha512-BSOAsENdmTtsnDL0hb4takbWzPy9WoPybjlM57ab3/rQgm0biMFYUupH2uzmCjmmIXJL/EFbAWznVl8xw2Sa6Q==", + "license": "MIT", + "dependencies": { + "socket.io": "4.8.3", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "rxjs": "^7.1.0" + } + }, "node_modules/@nestjs/schedule": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz", @@ -2499,6 +2521,29 @@ "typeorm": "^0.3.0" } }, + "node_modules/@nestjs/websockets": { + "version": "11.1.17", + "resolved": "https://registry.npmjs.org/@nestjs/websockets/-/websockets-11.1.17.tgz", + "integrity": "sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==", + "license": "MIT", + "dependencies": { + "iterare": "1.2.1", + "object-hash": "3.0.0", + "tslib": "2.8.1" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-socket.io": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/platform-socket.io": { + "optional": true + } + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -2648,6 +2693,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sqltools/formatter": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", @@ -2929,6 +2980,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -3177,6 +3237,15 @@ "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.35", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", @@ -4439,6 +4508,15 @@ ], "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.17", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz", @@ -5635,6 +5713,79 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.6.tgz", + "integrity": "sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", @@ -9227,6 +9378,15 @@ "node": ">=0.10.0" } }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -10434,6 +10594,90 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -12365,6 +12609,27 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index a79b7b9..3188107 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -33,10 +33,12 @@ "@nestjs/mapped-types": "^2.1.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.0.1", + "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/schedule": "^6.1.1", "@nestjs/swagger": "^11.2.0", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", + "@nestjs/websockets": "^11.1.17", "@stellar/stellar-sdk": "^14.5.0", "@types/bcrypt": "^6.0.0", "@types/passport-jwt": "^4.0.1", @@ -49,6 +51,7 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "socket.io": "^4.8.3", "sqlite3": "^5.1.7", "stellar-sdk": "^13.3.0", "swagger-ui-express": "^5.0.1", diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 124bd25..93b916f 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -26,6 +26,7 @@ import { ApiKey } from './api-key/entities/api-key.entity'; import { AdminAuditLog } from './modules/admin/entities/admin-audit-log.entity'; import { Webhook } from './modules/webhook/webhook.entity'; import { StellarEvent } from './modules/stellar/entities/stellar-event.entity'; +import { WebSocketModule } from './websocket/websocket.module'; @Module({ imports: [ @@ -71,6 +72,7 @@ import { StellarEvent } from './modules/stellar/entities/stellar-event.entity'; NotificationsModule, ApiKeyModule, StellarEventModule, + WebSocketModule, ], controllers: [AppController], providers: [AppService], diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 341744c..f4d0ba2 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -6,6 +6,14 @@ import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); + // Enable CORS for both HTTP and WebSocket connections + app.enableCors({ + origin: true, // Allow all origins in development; configure for production + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'], + }); + // Enable global validation app.useGlobalPipes( new ValidationPipe({ diff --git a/apps/backend/src/modules/escrow/escrow.module.ts b/apps/backend/src/modules/escrow/escrow.module.ts index e4612bb..db23800 100644 --- a/apps/backend/src/modules/escrow/escrow.module.ts +++ b/apps/backend/src/modules/escrow/escrow.module.ts @@ -17,6 +17,7 @@ import { StellarModule } from '../stellar/stellar.module'; import { EscrowStellarIntegrationService } from './services/escrow-stellar-integration.service'; import { WebhookModule } from '../webhook/webhook.module'; import { User } from '../user/entities/user.entity'; +import { WebSocketModule } from '../../websocket/websocket.module'; @Module({ imports: [ @@ -31,6 +32,7 @@ import { User } from '../user/entities/user.entity'; AuthModule, StellarModule, WebhookModule, + WebSocketModule, ], controllers: [EscrowController, EscrowSchedulerController, EventsController], providers: [ diff --git a/apps/backend/src/modules/escrow/services/escrow.service.ts b/apps/backend/src/modules/escrow/services/escrow.service.ts index e45907a..8dc0a2d 100644 --- a/apps/backend/src/modules/escrow/services/escrow.service.ts +++ b/apps/backend/src/modules/escrow/services/escrow.service.ts @@ -5,6 +5,8 @@ import { ForbiddenException, ConflictException, UnprocessableEntityException, + Inject, + forwardRef, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Brackets, Repository, SelectQueryBuilder } from 'typeorm'; @@ -39,6 +41,7 @@ import { validateTransition, isTerminalStatus } from '../escrow-state-machine'; import { EscrowStellarIntegrationService } from './escrow-stellar-integration.service'; import { WebhookService } from '../../../services/webhook/webhook.service'; import { User, UserRole } from '../../user/entities/user.entity'; +import { WebSocketEventsService } from '../../../websocket/websocket-events.service'; @Injectable() export class EscrowService { @@ -58,6 +61,8 @@ export class EscrowService { private readonly stellarIntegrationService: EscrowStellarIntegrationService, private readonly webhookService: WebhookService, + @Inject(forwardRef(() => WebSocketEventsService)) + private readonly wsEventsService: WebSocketEventsService, ) {} async create( @@ -385,8 +390,18 @@ export class EscrowService { validateTransition(escrow.status, EscrowStatus.CANCELLED); + const previousStatus = escrow.status; await this.escrowRepository.update(id, { status: EscrowStatus.CANCELLED }); + // Emit WebSocket event for status change + this.wsEventsService.emitEscrowStatusChanged({ + escrowId: id, + previousStatus, + newStatus: EscrowStatus.CANCELLED, + timestamp: new Date(), + actorId: userId, + }); + await this.logEvent( id, EscrowEventType.CANCELLED, @@ -471,12 +486,23 @@ export class EscrowService { ); const fundedAt = new Date(); + const previousStatus = escrow.status; await this.escrowRepository.update(id, { stellarTxHash, fundedAt, status: EscrowStatus.ACTIVE, }); + // Emit WebSocket event for status change + this.wsEventsService.emitEscrowStatusChanged({ + escrowId: id, + previousStatus, + newStatus: EscrowStatus.ACTIVE, + timestamp: fundedAt, + actorId: userId, + metadata: { stellarTxHash }, + }); + await this.logEvent( id, EscrowEventType.FUNDED, @@ -566,12 +592,23 @@ export class EscrowService { escrow.creatorId, ); + const previousStatus = escrow.status; escrow.status = EscrowStatus.COMPLETED; escrow.isReleased = true; escrow.releaseTransactionHash = txHash; await this.escrowRepository.save(escrow); + // Emit WebSocket event for status change + this.wsEventsService.emitEscrowStatusChanged({ + escrowId: escrow.id, + previousStatus, + newStatus: EscrowStatus.COMPLETED, + timestamp: new Date(), + actorId: currentUserId, + metadata: { txHash }, + }); + await this.logEvent(escrow.id, EscrowEventType.COMPLETED, currentUserId, { txHash, }); @@ -643,6 +680,15 @@ export class EscrowService { await this.conditionRepository.save(condition); + // Emit WebSocket event for condition fulfillment + this.wsEventsService.emitConditionFulfilled({ + escrowId, + conditionId, + description: condition.description, + fulfilledBy: userId, + timestamp: new Date(), + }); + await this.logEvent( escrowId, EscrowEventType.CONDITION_FULFILLED, @@ -728,6 +774,15 @@ export class EscrowService { await this.conditionRepository.save(condition); + // Emit WebSocket event for condition confirmation + this.wsEventsService.emitConditionConfirmed({ + escrowId, + conditionId, + description: condition.description, + confirmedBy: userId, + timestamp: new Date(), + }); + await this.logEvent( escrowId, EscrowEventType.CONDITION_MET, @@ -896,6 +951,15 @@ export class EscrowService { }); const savedDispute = await this.disputeRepository.save(dispute); + // Emit WebSocket event for dispute filed + this.wsEventsService.emitDisputeFiled({ + escrowId, + disputeId: savedDispute.id, + filedBy: userId, + reason: dto.reason, + timestamp: new Date(), + }); + await this.logEvent( escrowId, EscrowEventType.DISPUTE_FILED, @@ -990,6 +1054,15 @@ export class EscrowService { const resolved = await this.disputeRepository.save(dispute); + // Emit WebSocket event for dispute resolved + this.wsEventsService.emitDisputeResolved({ + escrowId, + disputeId: resolved.id, + outcome: dto.outcome, + resolvedBy: arbitratorUserId, + timestamp: new Date(), + }); + await this.logEvent( escrowId, EscrowEventType.DISPUTE_RESOLVED, @@ -1068,11 +1141,22 @@ export class EscrowService { validateTransition(escrow.status, EscrowStatus.EXPIRED); + const previousStatus = escrow.status; await this.escrowRepository.update(escrow.id, { status: EscrowStatus.EXPIRED, isActive: false, }); + // Emit WebSocket event for status change + this.wsEventsService.emitEscrowStatusChanged({ + escrowId: escrow.id, + previousStatus, + newStatus: EscrowStatus.EXPIRED, + timestamp: new Date(), + actorId: options.actorId, + metadata: { reason: options.reason }, + }); + await this.logEvent( escrow.id, EscrowEventType.EXPIRED, diff --git a/apps/backend/src/notifications/notifications.module.ts b/apps/backend/src/notifications/notifications.module.ts index 2a0f9f9..65e8160 100644 --- a/apps/backend/src/notifications/notifications.module.ts +++ b/apps/backend/src/notifications/notifications.module.ts @@ -7,9 +7,13 @@ import { NotificationService } from './notifications.service'; import { PreferenceService } from './preference.service'; import { EmailSender } from './senders/email.sender'; import { WebhookSender } from './senders/webhook.sender'; +import { WebSocketModule } from '../websocket/websocket.module'; @Module({ - imports: [TypeOrmModule.forFeature([Notification, NotificationPreference])], + imports: [ + TypeOrmModule.forFeature([Notification, NotificationPreference]), + WebSocketModule, + ], controllers: [NotificationController], providers: [ NotificationService, diff --git a/apps/backend/src/notifications/notifications.service.ts b/apps/backend/src/notifications/notifications.service.ts index 1477729..d6b7d61 100644 --- a/apps/backend/src/notifications/notifications.service.ts +++ b/apps/backend/src/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationChannel, @@ -12,6 +12,7 @@ import { WebhookSender } from './senders/webhook.sender'; import { Repository, IsNull } from 'typeorm'; import { EmailSender } from './senders/email.sender'; import { PreferenceService } from './preference.service'; +import { WebSocketEventsService } from '../websocket/websocket-events.service'; @Injectable() export class NotificationService { @@ -24,6 +25,8 @@ export class NotificationService { private preferenceService: PreferenceService, emailSender: EmailSender, webhookSender: WebhookSender, + @Inject(forwardRef(() => WebSocketEventsService)) + private readonly wsEventsService: WebSocketEventsService, ) { this.senders = new Map([ [NotificationChannel.EMAIL, emailSender], @@ -42,15 +45,25 @@ export class NotificationService { if (!pref.enabled) continue; if (!pref.eventTypes.includes(eventType)) continue; - await this.repo.save( - this.repo.create({ - userId, - eventType, - payload, - escrowId: (payload.escrowId as string) || undefined, - status: NotificationStatus.PENDING, - }), - ); + const notification = this.repo.create({ + userId, + eventType, + payload, + escrowId: (payload.escrowId as string) || undefined, + status: NotificationStatus.PENDING, + }); + const savedNotification = await this.repo.save(notification); + + // Emit WebSocket event for new notification + this.wsEventsService.emitNotification({ + notificationId: savedNotification.id, + userId, + eventType: eventType, + message: `Escrow event: ${eventType}`, + escrowId: (payload.escrowId as string) || undefined, + timestamp: new Date(), + data: payload, + }); } } diff --git a/apps/backend/src/websocket/events.gateway.ts b/apps/backend/src/websocket/events.gateway.ts new file mode 100644 index 0000000..761a4ed --- /dev/null +++ b/apps/backend/src/websocket/events.gateway.ts @@ -0,0 +1,281 @@ +import { + WebSocketGateway, + WebSocketServer, + SubscribeMessage, + OnGatewayInit, + OnGatewayConnection, + OnGatewayDisconnect, + ConnectedSocket, + MessageBody, + WsException, +} from '@nestjs/websockets'; +import { Logger } from '@nestjs/common'; +import { Server, Socket } from 'socket.io'; +import { WebSocketEventsService } from './websocket-events.service'; +import { ConfigService } from '@nestjs/config'; + +// Event names for type safety +export enum WsEvent { + // Escrow events + ESCROW_STATUS_CHANGED = 'escrow.status_changed', + ESCROW_CONDITION_FULFILLED = 'escrow.condition_fulfilled', + ESCROW_CONDITION_CONFIRMED = 'escrow.condition_confirmed', + ESCROW_DISPUTE_FILED = 'escrow.dispute_filed', + ESCROW_DISPUTE_RESOLVED = 'escrow.dispute_resolved', + // Notification events + NOTIFICATION_NEW = 'notification.new', + // Connection events + SUBSCRIBE_ESCROW = 'subscribe:escrow', + UNSUBSCRIBE_ESCROW = 'unsubscribe:escrow', + SUBSCRIBE_NOTIFICATIONS = 'subscribe:notifications', + MISSED_EVENTS = 'missed_events', +} + +// Payload interfaces +export interface EscrowStatusChangedPayload { + escrowId: string; + previousStatus: string; + newStatus: string; + timestamp: Date; + actorId?: string; + metadata?: Record; +} + +export interface ConditionPayload { + escrowId: string; + conditionId: string; + description?: string; + fulfilledBy?: string; + confirmedBy?: string; + timestamp: Date; +} + +export interface DisputePayload { + escrowId: string; + disputeId: string; + filedBy: string; + reason?: string; + timestamp: Date; +} + +export interface DisputeResolvedPayload { + escrowId: string; + disputeId: string; + outcome: string; + resolvedBy: string; + timestamp: Date; +} + +export interface NotificationPayload { + notificationId: string; + userId: string; + eventType: string; + message: string; + escrowId?: string; + timestamp: Date; + data?: Record; +} + +interface SocketData { + user: { + userId: string; + walletAddress: string; + }; + subscribedEscrows?: Set; + subscribedNotifications?: boolean; + lastEventTimestamp?: number; +} + +interface AuthenticatedSocket extends Socket { + data: SocketData; +} + +@WebSocketGateway({ + namespace: '/events', + cors: { + origin: true, // Allow all origins in development; configure for production + credentials: true, + }, + transports: ['websocket', 'polling'], +}) +export class EventsGateway + implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + private server!: Server; + + private readonly logger = new Logger(EventsGateway.name); + + constructor( + private readonly wsEventsService: WebSocketEventsService, + private readonly configService: ConfigService, + ) {} + + afterInit(server: Server): void { + this.logger.log('WebSocket Gateway initialized on namespace /events'); + // Make the server available to the events service + this.wsEventsService.setServer(server); + } + + async handleConnection(client: AuthenticatedSocket): Promise { + try { + // Extract token and validate + const token = this.extractToken(client); + if (!token) { + this.logger.warn( + `Connection rejected: No token. Socket ID: ${client.id}`, + ); + client.emit('error', { message: 'Authentication required' }); + client.disconnect(true); + return; + } + + // Verify token manually for connection validation + const jwt = await import('jsonwebtoken'); + const secret = this.configService.get( + 'JWT_SECRET', + 'your-secret-key-change-in-production', + ); + + const payload = jwt.verify(token, secret) as { + sub: string; + walletAddress: string; + type: string; + }; + + if (payload.type !== 'access') { + throw new Error('Invalid token type'); + } + + // Initialize socket data + client.data = { + user: { + userId: payload.sub, + walletAddress: payload.walletAddress, + }, + subscribedEscrows: new Set(), + subscribedNotifications: false, + lastEventTimestamp: Date.now(), + }; + + this.logger.log( + `Client connected: userId=${payload.sub}, socketId=${client.id}`, + ); + + // Join user's personal notification room + void client.join(`user:${payload.sub}`); + + // Send connection confirmation + client.emit('connection:established', { + message: 'Connected to Vaultix events stream', + userId: payload.sub, + timestamp: new Date().toISOString(), + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn( + `Connection rejected: ${errorMessage}. Socket ID: ${client.id}`, + ); + client.emit('error', { message: 'Authentication failed' }); + client.disconnect(true); + } + } + + handleDisconnect(client: AuthenticatedSocket): void { + const userId = client.data?.user?.userId; + this.logger.log( + `Client disconnected: userId=${userId}, socketId=${client.id}`, + ); + } + + @SubscribeMessage(WsEvent.SUBSCRIBE_ESCROW) + handleSubscribeEscrow( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { escrowId: string }, + ): void { + if (!data?.escrowId) { + throw new WsException('escrowId is required'); + } + + const room = `escrow:${data.escrowId}`; + void client.join(room); + client.data.subscribedEscrows?.add(data.escrowId); + + this.logger.debug( + `Client subscribed to escrow: userId=${client.data.user.userId}, escrowId=${data.escrowId}`, + ); + + client.emit('subscribed', { + room, + escrowId: data.escrowId, + timestamp: new Date().toISOString(), + }); + } + + @SubscribeMessage(WsEvent.UNSUBSCRIBE_ESCROW) + handleUnsubscribeEscrow( + @ConnectedSocket() client: AuthenticatedSocket, + @MessageBody() data: { escrowId: string }, + ): void { + if (!data?.escrowId) { + throw new WsException('escrowId is required'); + } + + const room = `escrow:${data.escrowId}`; + void client.leave(room); + client.data.subscribedEscrows?.delete(data.escrowId); + + this.logger.debug( + `Client unsubscribed from escrow: userId=${client.data.user.userId}, escrowId=${data.escrowId}`, + ); + + client.emit('unsubscribed', { + room, + escrowId: data.escrowId, + timestamp: new Date().toISOString(), + }); + } + + @SubscribeMessage(WsEvent.SUBSCRIBE_NOTIFICATIONS) + handleSubscribeNotifications( + @ConnectedSocket() client: AuthenticatedSocket, + ): void { + client.data.subscribedNotifications = true; + + this.logger.debug( + `Client subscribed to notifications: userId=${client.data.user.userId}`, + ); + + client.emit('notifications:subscribed', { + timestamp: new Date().toISOString(), + }); + } + + @SubscribeMessage('ping') + handlePing(@ConnectedSocket() client: AuthenticatedSocket): void { + client.emit('pong', { timestamp: Date.now() }); + } + + private extractToken(client: Socket): string | null { + // Try authorization header first + const authHeader = client.handshake.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Try query parameter + const tokenFromQuery = client.handshake.query.token; + if (typeof tokenFromQuery === 'string' && tokenFromQuery) { + return tokenFromQuery; + } + + // Try auth object in handshake + const auth = client.handshake.auth; + if (auth?.token && typeof auth.token === 'string') { + return auth.token; + } + + return null; + } +} diff --git a/apps/backend/src/websocket/index.ts b/apps/backend/src/websocket/index.ts new file mode 100644 index 0000000..b7acb2f --- /dev/null +++ b/apps/backend/src/websocket/index.ts @@ -0,0 +1,5 @@ +// WebSocket Module Exports +export * from './websocket.module'; +export * from './events.gateway'; +export * from './websocket-events.service'; +export * from './ws-jwt.guard'; diff --git a/apps/backend/src/websocket/websocket-events.service.ts b/apps/backend/src/websocket/websocket-events.service.ts new file mode 100644 index 0000000..0a57a5a --- /dev/null +++ b/apps/backend/src/websocket/websocket-events.service.ts @@ -0,0 +1,214 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { Server } from 'socket.io'; +import { + WsEvent, + EscrowStatusChangedPayload, + ConditionPayload, + DisputePayload, + DisputeResolvedPayload, + NotificationPayload, +} from './events.gateway'; + +@Injectable() +export class WebSocketEventsService implements OnModuleInit { + private server: Server | null = null; + private readonly logger = new Logger(WebSocketEventsService.name); + + // In-memory store for recent events (for reconnection support) + // Keyed by escrowId or userId for notifications + private readonly recentEvents: Map< + string, + Array<{ event: string; payload: unknown; timestamp: number }> + > = new Map(); + private readonly maxEventsPerKey = 50; + private readonly eventTtlMs = 5 * 60 * 1000; // 5 minutes + + onModuleInit(): void { + // Clean up old events periodically + setInterval(() => this.cleanupOldEvents(), 60000); // Every minute + } + + setServer(server: Server): void { + this.server = server; + this.logger.log('WebSocket server reference set'); + } + + /** + * Emit escrow status change event to all subscribers + */ + emitEscrowStatusChanged(payload: EscrowStatusChangedPayload): void { + const eventName = WsEvent.ESCROW_STATUS_CHANGED; + this.storeEvent(`escrow:${payload.escrowId}`, eventName, payload); + this.emitToRoom(`escrow:${payload.escrowId}`, eventName, payload); + + // Also emit to the depositor and recipient user channels + if (payload.actorId) { + this.emitToRoom(`user:${payload.actorId}`, eventName, payload); + } + + this.logger.debug( + `Emitted ${eventName} for escrow ${payload.escrowId}: ${payload.previousStatus} -> ${payload.newStatus}`, + ); + } + + /** + * Emit condition fulfilled event + */ + emitConditionFulfilled(payload: ConditionPayload): void { + const eventName = WsEvent.ESCROW_CONDITION_FULFILLED; + this.storeEvent(`escrow:${payload.escrowId}`, eventName, payload); + this.emitToRoom(`escrow:${payload.escrowId}`, eventName, payload); + + this.logger.debug( + `Emitted ${eventName} for condition ${payload.conditionId} in escrow ${payload.escrowId}`, + ); + } + + /** + * Emit condition confirmed event + */ + emitConditionConfirmed(payload: ConditionPayload): void { + const eventName = WsEvent.ESCROW_CONDITION_CONFIRMED; + this.storeEvent(`escrow:${payload.escrowId}`, eventName, payload); + this.emitToRoom(`escrow:${payload.escrowId}`, eventName, payload); + + this.logger.debug( + `Emitted ${eventName} for condition ${payload.conditionId} in escrow ${payload.escrowId}`, + ); + } + + /** + * Emit dispute filed event + */ + emitDisputeFiled(payload: DisputePayload): void { + const eventName = WsEvent.ESCROW_DISPUTE_FILED; + this.storeEvent(`escrow:${payload.escrowId}`, eventName, payload); + this.emitToRoom(`escrow:${payload.escrowId}`, eventName, payload); + + // Also emit to the filing user + this.emitToRoom(`user:${payload.filedBy}`, eventName, payload); + + this.logger.debug(`Emitted ${eventName} for escrow ${payload.escrowId}`); + } + + /** + * Emit dispute resolved event + */ + emitDisputeResolved(payload: DisputeResolvedPayload): void { + const eventName = WsEvent.ESCROW_DISPUTE_RESOLVED; + this.storeEvent(`escrow:${payload.escrowId}`, eventName, payload); + this.emitToRoom(`escrow:${payload.escrowId}`, eventName, payload); + + // Also emit to the resolver user + this.emitToRoom(`user:${payload.resolvedBy}`, eventName, payload); + + this.logger.debug(`Emitted ${eventName} for escrow ${payload.escrowId}`); + } + + /** + * Emit new notification to a specific user + */ + emitNotification(payload: NotificationPayload): void { + const eventName = WsEvent.NOTIFICATION_NEW; + this.storeEvent(`user:${payload.userId}`, eventName, payload); + this.emitToRoom(`user:${payload.userId}`, eventName, payload); + + this.logger.debug(`Emitted ${eventName} to user ${payload.userId}`); + } + + /** + * Get missed events for a user since a given timestamp + */ + getMissedEvents( + key: string, + sinceTimestamp: number, + ): Array<{ event: string; payload: unknown }> { + const events = this.recentEvents.get(key); + if (!events) { + return []; + } + + return events + .filter((e) => e.timestamp > sinceTimestamp) + .map((e) => ({ event: e.event, payload: e.payload })); + } + + /** + * Get missed events for an escrow + */ + getMissedEscrowEvents( + escrowId: string, + sinceTimestamp: number, + ): Array<{ event: string; payload: unknown }> { + return this.getMissedEvents(`escrow:${escrowId}`, sinceTimestamp); + } + + /** + * Get missed events for a user's notifications + */ + getMissedUserEvents( + userId: string, + sinceTimestamp: number, + ): Array<{ event: string; payload: unknown }> { + return this.getMissedEvents(`user:${userId}`, sinceTimestamp); + } + + /** + * Emit to a specific room + */ + private emitToRoom(room: string, event: string, payload: unknown): void { + if (!this.server) { + this.logger.warn('Cannot emit: WebSocket server not initialized'); + return; + } + + this.server.to(room).emit(event, payload); + } + + /** + * Store event for reconnection support + */ + private storeEvent(key: string, event: string, payload: unknown): void { + if (!this.recentEvents.has(key)) { + this.recentEvents.set(key, []); + } + + const events = this.recentEvents.get(key)!; + events.push({ + event, + payload, + timestamp: Date.now(), + }); + + // Trim to max size + if (events.length > this.maxEventsPerKey) { + events.shift(); + } + } + + /** + * Clean up old events past TTL + */ + private cleanupOldEvents(): void { + const now = Date.now(); + let cleaned = 0; + + for (const [key, events] of this.recentEvents.entries()) { + const filtered = events.filter( + (e) => now - e.timestamp < this.eventTtlMs, + ); + if (filtered.length !== events.length) { + cleaned += events.length - filtered.length; + if (filtered.length === 0) { + this.recentEvents.delete(key); + } else { + this.recentEvents.set(key, filtered); + } + } + } + + if (cleaned > 0) { + this.logger.debug(`Cleaned up ${cleaned} old events from memory`); + } + } +} diff --git a/apps/backend/src/websocket/websocket.module.ts b/apps/backend/src/websocket/websocket.module.ts new file mode 100644 index 0000000..57ffe1d --- /dev/null +++ b/apps/backend/src/websocket/websocket.module.ts @@ -0,0 +1,26 @@ +import { Module, Global } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { EventsGateway } from './events.gateway'; +import { WebSocketEventsService } from './websocket-events.service'; +import { WsJwtGuard } from './ws-jwt.guard'; + +@Global() +@Module({ + imports: [ + ConfigModule, + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + secret: configService.get( + 'JWT_SECRET', + 'your-secret-key-change-in-production', + ), + }), + inject: [ConfigService], + }), + ], + providers: [EventsGateway, WebSocketEventsService, WsJwtGuard], + exports: [WebSocketEventsService, WsJwtGuard], +}) +export class WebSocketModule {} diff --git a/apps/backend/src/websocket/ws-jwt.guard.ts b/apps/backend/src/websocket/ws-jwt.guard.ts new file mode 100644 index 0000000..8746fc6 --- /dev/null +++ b/apps/backend/src/websocket/ws-jwt.guard.ts @@ -0,0 +1,108 @@ +import { + CanActivate, + ExecutionContext, + Inject, + Injectable, + Logger, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { Socket } from 'socket.io'; +import { WsException } from '@nestjs/websockets'; + +interface JwtPayload { + sub: string; + walletAddress: string; + type: string; +} + +interface AuthenticatedSocket extends Socket { + data: { + user: { + userId: string; + walletAddress: string; + }; + }; +} + +@Injectable() +export class WsJwtGuard implements CanActivate { + private readonly logger = new Logger(WsJwtGuard.name); + + constructor( + @Inject(JwtService) + private readonly jwtService: JwtService, + @Inject(ConfigService) + private readonly configService: ConfigService, + ) {} + + canActivate(context: ExecutionContext): boolean { + const client: AuthenticatedSocket = context.switchToWs().getClient(); + + const token = this.extractTokenFromHandshake(client); + + if (!token) { + this.logger.warn( + `WebSocket connection rejected: No token provided. Socket ID: ${client.id}`, + ); + throw new WsException('Authentication token required'); + } + + try { + const secret = this.configService.get( + 'JWT_SECRET', + 'your-secret-key-change-in-production', + ); + + const payload = this.jwtService.verify(token, { secret }); + + if (payload.type !== 'access') { + this.logger.warn( + `WebSocket connection rejected: Invalid token type. Socket ID: ${client.id}`, + ); + throw new WsException('Invalid token type'); + } + + // Attach user info to socket data for later use + client.data.user = { + userId: payload.sub, + walletAddress: payload.walletAddress, + }; + + this.logger.debug( + `WebSocket authenticated: userId=${payload.sub}, socketId=${client.id}`, + ); + + return true; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.logger.warn( + `WebSocket connection rejected: Invalid token. Error: ${errorMessage}. Socket ID: ${client.id}`, + ); + throw new WsException('Invalid or expired token'); + } + } + + private extractTokenFromHandshake(client: Socket): string | null { + // Try authorization header first + const authHeader = client.handshake.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + // Try query parameter + const tokenFromQuery = client.handshake.query.token; + if (typeof tokenFromQuery === 'string' && tokenFromQuery) { + return tokenFromQuery; + } + + // Try auth object in handshake + const auth = client.handshake.auth; + if (auth?.token && typeof auth.token === 'string') { + return auth.token; + } + + return null; + } +}