diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 88bfd379..d449d158 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -822,6 +822,9 @@ model Session { refreshTokenJti String? @map("refresh_token_jti") ipAddress String? @map("ip_address") userAgent String? @map("user_agent") + displayName String? @map("display_name") + deviceInfo Json? @map("device_info") + geoLocation Json? @map("geo_location") isRevoked Boolean @default(false) @map("is_revoked") revokedAt DateTime? @map("revoked_at") expiresAt DateTime @map("expires_at") diff --git a/src/fraud/fraud.module.ts b/src/fraud/fraud.module.ts index 0c0cfdbb..75b8e738 100644 --- a/src/fraud/fraud.module.ts +++ b/src/fraud/fraud.module.ts @@ -4,10 +4,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { PrismaModule } from '../database/prisma.module'; import { EmailModule } from '../email/email.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { FraudService } from './fraud.service'; @Module({ - imports: [ConfigModule, PrismaModule, EmailModule], + imports: [ConfigModule, PrismaModule, EmailModule, NotificationsModule], providers: [FraudService], exports: [FraudService], }) diff --git a/src/fraud/fraud.service.spec.ts b/src/fraud/fraud.service.spec.ts index 7e33c780..de32688d 100644 --- a/src/fraud/fraud.service.spec.ts +++ b/src/fraud/fraud.service.spec.ts @@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config'; import { FraudService } from './fraud.service'; import { PrismaService } from '../database/prisma.service'; import { EmailService } from '../email/email.service'; +import { SmsService } from '../notifications/sms.service'; import { FraudPattern, FraudSeverity } from '../types/prisma.types'; describe('FraudService', () => { @@ -67,6 +68,7 @@ describe('FraudService', () => { { provide: PrismaService, useValue: mockPrismaService }, { provide: EmailService, useValue: mockEmailService }, { provide: ConfigService, useValue: mockConfigService }, + { provide: SmsService, useValue: { sendSms: jest.fn().mockResolvedValue(true) } }, ], }).compile(); diff --git a/src/fraud/fraud.service.ts b/src/fraud/fraud.service.ts index 4801d283..058e0d95 100644 --- a/src/fraud/fraud.service.ts +++ b/src/fraud/fraud.service.ts @@ -5,6 +5,7 @@ import { Prisma } from '@prisma/client'; import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../database/prisma.service'; import { EmailService } from '../email/email.service'; +import { SmsService } from '../notifications/sms.service'; import { AddFraudInvestigationNoteDto, BlockFraudUserDto, @@ -47,6 +48,7 @@ export class FraudService { constructor( private readonly prisma: PrismaService, private readonly emailService: EmailService, + private readonly smsService: SmsService, private readonly configService: ConfigService, ) { this.fraudAlertRecipients = (this.configService.get('FRAUD_ALERT_RECIPIENTS') ?? '') @@ -686,10 +688,8 @@ export class FraudService { }, }); - // Send notification only if severity increased or occurrence count is a multiple of 5 - // This prevents spamming admins with repeated low-severity alerts if (severityIncreased || updated.occurrenceCount % 5 === 0) { - await this.notifySecurityTeam(updated, true); + await this.deliverMultiChannelNotifications(updated, payload); } if ( @@ -753,7 +753,7 @@ export class FraudService { }); } - await this.notifySecurityTeam(created, false); + await this.deliverMultiChannelNotifications(created, payload); if (payload.autoBlockUser && payload.userId) { await this.blockUserForFraud( @@ -767,6 +767,88 @@ export class FraudService { return created; } + /** + * Multi-channel notification delivery based on alert severity: + * - ALL severities: in-app + email to security team + * - HIGH + CRITICAL: email to user + * - CRITICAL: SMS to user (if available) + */ + private async deliverMultiChannelNotifications(alert: any, payload: AlertPayload) { + try { + await this.notifySecurityTeam(alert, false); + } catch (error) { + this.logger.error(`Failed to send security team notification: ${error.message}`); + } + + if ( + (payload.severity === FraudSeverity.HIGH || payload.severity === FraudSeverity.CRITICAL) && + alert.user?.email + ) { + try { + await this.emailService.sendEmail({ + to: alert.user.email, + subject: `[Security Alert][${payload.severity}] ${payload.title}`, + html: ` +

Fraud Alert - ${payload.severity}

+

${payload.description}

+

If you did not perform this action, please secure your account immediately and contact support.

+ `, + userId: payload.userId, + emailType: 'FRAUD_ALERT', + }); + } catch (error) { + this.logger.error(`Failed to send fraud alert email to user: ${error.message}`); + } + } + + if (payload.severity === FraudSeverity.CRITICAL && alert.user?.email) { + try { + const phone = await this.getUserPhone(payload.userId); + if (phone) { + await this.smsService.sendSms( + phone, + `[CRITICAL Security Alert] ${payload.title}. ${payload.description} Please secure your account immediately.`, + ); + } else { + this.logger.warn(`No phone number for user ${payload.userId}, skipping SMS notification`); + } + } catch (error) { + this.logger.error(`Failed to send fraud alert SMS: ${error.message}`); + } + } + + if (payload.userId) { + try { + await this.prisma.notification.create({ + data: { + userId: payload.userId, + title: `Security Alert: ${payload.title}`, + message: payload.description, + type: 'FRAUD_ALERT', + status: 'PENDING', + metadata: { + severity: payload.severity, + pattern: payload.pattern, + score: payload.score, + alertId: alert.id, + }, + }, + }); + } catch (error) { + this.logger.error(`Failed to create in-app notification: ${error.message}`); + } + } + } + + private async getUserPhone(userId?: string): Promise { + if (!userId) return null; + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { phone: true }, + }); + return user?.phone ?? null; + } + private async findOpenAlert(payload: AlertPayload) { return this.prisma.fraudAlert.findFirst({ where: { diff --git a/src/notifications/notifications.gateway.ts b/src/notifications/notifications.gateway.ts index b1f539b3..e02ab79a 100644 --- a/src/notifications/notifications.gateway.ts +++ b/src/notifications/notifications.gateway.ts @@ -5,6 +5,9 @@ import { WebSocketServer, OnGatewayConnection, OnGatewayDisconnect, + SubscribeMessage, + ConnectedSocket, + MessageBody, } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; import { Logger } from '@nestjs/common'; @@ -20,7 +23,8 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco server: Server; private logger: Logger = new Logger('NotificationsGateway'); - private userSockets = new Map(); // userId -> socketIds + private userSockets = new Map(); + private socketUsers = new Map(); handleConnection(client: Socket) { const userId = client.handshake.query.userId as string; @@ -28,12 +32,16 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco const sockets = this.userSockets.get(userId) || []; sockets.push(client.id); this.userSockets.set(userId, sockets); + this.socketUsers.set(client.id, userId); + + client.join(`user:${userId}`); + this.logger.log(`User ${userId} connected (${client.id})`); } } handleDisconnect(client: Socket) { - const userId = client.handshake.query.userId as string; + const userId = this.socketUsers.get(client.id); if (userId) { const sockets = this.userSockets.get(userId) || []; const index = sockets.indexOf(client.id); @@ -43,18 +51,128 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco if (sockets.length === 0) { this.userSockets.delete(userId); } + this.socketUsers.delete(client.id); this.logger.log(`User ${userId} disconnected (${client.id})`); } } - sendToUser(userId: string, event: string, data: any): boolean { - const sockets = this.userSockets.get(userId); - if (sockets && sockets.length > 0) { - sockets.forEach((socketId) => { - this.server.to(socketId).emit(event, data); - }); - return true; + @SubscribeMessage('joinProperty') + handleJoinProperty(@ConnectedSocket() client: Socket, @MessageBody() data: { propertyId: string }) { + if (data?.propertyId) { + client.join(`property:${data.propertyId}`); + this.logger.log(`Client ${client.id} joined property room ${data.propertyId}`); + return { event: 'joinedProperty', data: { propertyId: data.propertyId } }; + } + return { event: 'error', data: { message: 'propertyId is required' } }; + } + + @SubscribeMessage('leaveProperty') + handleLeaveProperty(@ConnectedSocket() client: Socket, @MessageBody() data: { propertyId: string }) { + if (data?.propertyId) { + client.leave(`property:${data.propertyId}`); + this.logger.log(`Client ${client.id} left property room ${data.propertyId}`); + return { event: 'leftProperty', data: { propertyId: data.propertyId } }; + } + } + + @SubscribeMessage('joinTransaction') + handleJoinTransaction( + @ConnectedSocket() client: Socket, + @MessageBody() data: { transactionId: string }, + ) { + if (data?.transactionId) { + client.join(`transaction:${data.transactionId}`); + this.logger.log(`Client ${client.id} joined transaction room ${data.transactionId}`); + return { event: 'joinedTransaction', data: { transactionId: data.transactionId } }; + } + return { event: 'error', data: { message: 'transactionId is required' } }; + } + + @SubscribeMessage('leaveTransaction') + handleLeaveTransaction( + @ConnectedSocket() client: Socket, + @MessageBody() data: { transactionId: string }, + ) { + if (data?.transactionId) { + client.leave(`transaction:${data.transactionId}`); + return { event: 'leftTransaction', data: { transactionId: data.transactionId } }; + } + } + + @SubscribeMessage('joinUser') + handleJoinUser(@ConnectedSocket() client: Socket, @MessageBody() data: { userId: string }) { + const socketUserId = this.socketUsers.get(client.id); + if (socketUserId && socketUserId === data?.userId) { + client.join(`user:${data.userId}`); + return { event: 'joinedUser', data: { userId: data.userId } }; } - return false; + return { event: 'error', data: { message: 'Unauthorized to join this user room' } }; + } + + // -- Emit helpers for property events -- + + emitPropertyCreated(propertyId: string, data: any) { + this.server.to(`property:${propertyId}`).emit('property:created', { propertyId, ...data }); + this.logger.log(`Emitted property:created for ${propertyId}`); + } + + emitPropertyUpdated(propertyId: string, data: any) { + this.server.to(`property:${propertyId}`).emit('property:updated', { propertyId, ...data }); + this.logger.log(`Emitted property:updated for ${propertyId}`); + } + + emitPropertyPriceChanged(propertyId: string, data: any) { + this.server.to(`property:${propertyId}`).emit('property:price_changed', { propertyId, ...data }); + this.logger.log(`Emitted property:price_changed for ${propertyId}`); + } + + // -- Emit helpers for transaction events -- + + emitTransactionCreated(transactionId: string, data: any) { + this.server + .to(`transaction:${transactionId}`) + .emit('transaction:created', { transactionId, ...data }); + this.logger.log(`Emitted transaction:created for ${transactionId}`); + } + + emitTransactionStatusChanged(transactionId: string, data: any) { + this.server + .to(`transaction:${transactionId}`) + .emit('transaction:status_changed', { transactionId, ...data }); + this.logger.log(`Emitted transaction:status_changed for ${transactionId}`); + } + + // -- Emit helpers for document events -- + + emitDocumentUploaded(data: any) { + this.server.emit('document:uploaded', data); + this.logger.log(`Emitted document:uploaded`); + } + + emitDocumentSigned(data: any) { + this.server.emit('document:signed', data); + this.logger.log(`Emitted document:signed`); + } + + emitDocumentExpired(data: any) { + this.server.emit('document:expired', data); + this.logger.log(`Emitted document:expired`); + } + + // -- Emit helpers for fraud events -- + + emitFraudAlert(userId: string, data: any) { + this.sendToUser(userId, 'fraud:alert', data); + } + + // -- Generic user-targeted send -- + + sendToUser(userId: string, event: string, data: any): boolean { + this.server.to(`user:${userId}`).emit(event, data); + return true; + } + + sendToAll(event: string, data: any) { + this.server.emit(event, data); } } diff --git a/src/sessions/dto/session.dto.ts b/src/sessions/dto/session.dto.ts index 013f2714..cad6681c 100644 --- a/src/sessions/dto/session.dto.ts +++ b/src/sessions/dto/session.dto.ts @@ -1,16 +1,30 @@ // @ts-nocheck +import { IsOptional, IsString, MaxLength } from 'class-validator'; + export class SessionDto { id: string; accessTokenJti: string; refreshTokenJti?: string; ipAddress?: string; userAgent?: string; + displayName?: string; + deviceInfo?: { + browser?: string; + os?: string; + deviceType?: string; + }; + geoLocation?: { + country?: string; + city?: string; + region?: string; + }; isRevoked: boolean; expiresAt: Date; createdAt: Date; lastActivityAt: Date; revokedAt?: Date; + isCurrent?: boolean; } export class SessionsListDto { @@ -28,3 +42,22 @@ export class RevokeAllSessionsDto { message: string; revokedCount: number; } + +export class UpdateSessionDto { + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; +} + +export class SessionDeviceDto { + browser?: string; + os?: string; + deviceType?: string; +} + +export class SessionGeoDto { + country?: string; + city?: string; + region?: string; +} diff --git a/src/sessions/sessions.controller.ts b/src/sessions/sessions.controller.ts index 0b8c9d27..32ba856c 100644 --- a/src/sessions/sessions.controller.ts +++ b/src/sessions/sessions.controller.ts @@ -1,11 +1,11 @@ // @ts-nocheck -import { Controller, Delete, Get, Param, UseGuards } from '@nestjs/common'; +import { Controller, Delete, Get, Param, Patch, Body, UseGuards } from '@nestjs/common'; import { SessionsService } from './sessions.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { AuthUserPayload } from '../auth/types/auth-user.type'; -import { SessionsListDto, RevokeSessionDto, RevokeAllSessionsDto } from './dto/session.dto'; +import { SessionsListDto, RevokeSessionDto, RevokeAllSessionsDto, UpdateSessionDto } from './dto/session.dto'; @Controller('sessions') @UseGuards(JwtAuthGuard) @@ -28,6 +28,18 @@ export class SessionsController { return this.sessionsService.getSession(sessionId); } + /** + * Rename a session (update display name) + */ + @Patch(':sessionId') + async updateSession( + @Param('sessionId') sessionId: string, + @Body() dto: UpdateSessionDto, + @CurrentUser() user: AuthUserPayload, + ): Promise { + return this.sessionsService.updateSession(user.sub, sessionId, dto); + } + /** * Revoke a specific session */ @@ -44,8 +56,6 @@ export class SessionsController { */ @Delete() async revokeAllSessions(@CurrentUser() user: AuthUserPayload): Promise { - // If current session is tracked via JWT JTI, we could pass it to keep it active - // For now, we'll revoke all sessions return this.sessionsService.revokeAllSessions(user.sub); } } diff --git a/src/sessions/sessions.module.ts b/src/sessions/sessions.module.ts index b1dc56a4..d17c382e 100644 --- a/src/sessions/sessions.module.ts +++ b/src/sessions/sessions.module.ts @@ -1,12 +1,13 @@ // @ts-nocheck import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { SessionsService } from './sessions.service'; import { SessionsController } from './sessions.controller'; import { PrismaModule } from '../database/prisma.module'; @Module({ - imports: [PrismaModule], + imports: [ConfigModule, PrismaModule], controllers: [SessionsController], providers: [SessionsService], exports: [SessionsService], diff --git a/src/sessions/sessions.service.ts b/src/sessions/sessions.service.ts index 63286e67..c9bf166d 100644 --- a/src/sessions/sessions.service.ts +++ b/src/sessions/sessions.service.ts @@ -1,20 +1,30 @@ // @ts-nocheck -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, ConflictException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { PrismaService } from '../database/prisma.service'; import { SessionDto, SessionsListDto, RevokeSessionDto, RevokeAllSessionsDto, + UpdateSessionDto, } from './dto/session.dto'; @Injectable() export class SessionsService { - constructor(private prisma: PrismaService) {} + private readonly logger = new Logger(SessionsService.name); + private readonly maxConcurrentSessions: number; + + constructor( + private prisma: PrismaService, + private configService: ConfigService, + ) { + this.maxConcurrentSessions = this.configService.get('MAX_CONCURRENT_SESSIONS', 5); + } /** - * Create a new session + * Create a new session with device tracking and geo-location */ async createSession( userId: string, @@ -22,8 +32,24 @@ export class SessionsService { refreshTokenJti: string, ipAddress?: string, userAgent?: string, - expiresInSeconds: number = 7 * 24 * 60 * 60, // 7 days + expiresInSeconds: number = 7 * 24 * 60 * 60, ): Promise { + const activeSessionCount = await this.prisma.session.count({ + where: { + userId, + isRevoked: false, + expiresAt: { gt: new Date() }, + }, + }); + + if (activeSessionCount >= this.maxConcurrentSessions) { + throw new ConflictException( + `Maximum concurrent sessions (${this.maxConcurrentSessions}) reached. Please terminate an existing session first.`, + ); + } + + const deviceInfo = this.parseDeviceInfo(userAgent); + const geoLocation = this.lookupGeoFromIp(ipAddress); const expiresAt = new Date(Date.now() + expiresInSeconds * 1000); const session = await this.prisma.session.create({ @@ -33,6 +59,8 @@ export class SessionsService { refreshTokenJti, ipAddress, userAgent, + deviceInfo: deviceInfo as any, + geoLocation: geoLocation as any, expiresAt, }, }); @@ -43,7 +71,7 @@ export class SessionsService { /** * Get all sessions for a user */ - async getUserSessions(userId: string): Promise { + async getUserSessions(userId: string, currentAccessTokenJti?: string): Promise { const sessions = await this.prisma.session.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, @@ -53,7 +81,9 @@ export class SessionsService { const revokedSessions = sessions.filter((s: any) => s.isRevoked); return { - sessions: sessions.map((s: any) => this.mapSessionToDto(s)), + sessions: sessions.map((s: any) => + this.mapSessionToDto(s, s.accessTokenJti === currentAccessTokenJti), + ), activeCount: activeSessions.length, revokedCount: revokedSessions.length, }; @@ -74,6 +104,36 @@ export class SessionsService { return this.mapSessionToDto(session); } + /** + * Rename a session (update display name) + */ + async updateSession( + userId: string, + sessionId: string, + dto: UpdateSessionDto, + ): Promise { + const session = await this.prisma.session.findUnique({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Session not found'); + } + + if (session.userId !== userId) { + throw new NotFoundException('Session not found'); + } + + const updated = await this.prisma.session.update({ + where: { id: sessionId }, + data: { + displayName: dto.displayName, + }, + }); + + return this.mapSessionToDto(updated); + } + /** * Revoke a specific session */ @@ -189,7 +249,6 @@ export class SessionsService { return false; } - // Session is valid if it's not revoked and hasn't expired return !session.isRevoked && session.expiresAt > new Date(); } @@ -223,21 +282,79 @@ export class SessionsService { return result.count; } + /** + * Parse User-Agent string to extract device information + */ + private parseDeviceInfo(userAgent?: string): { browser?: string; os?: string; deviceType?: string } { + if (!userAgent) return {}; + + const browser = this.extractBrowser(userAgent); + const os = this.extractOs(userAgent); + const deviceType = this.extractDeviceType(userAgent); + + return { browser, os, deviceType }; + } + + private extractBrowser(ua: string): string | undefined { + if (ua.includes('Firefox/')) return 'Firefox'; + if (ua.includes('Edg/')) return 'Edge'; + if (ua.includes('Chrome/')) return 'Chrome'; + if (ua.includes('Safari/') && !ua.includes('Chrome')) return 'Safari'; + if (ua.includes('Opera/') || ua.includes('OPR/')) return 'Opera'; + if (ua.includes('MSIE') || ua.includes('Trident/')) return 'Internet Explorer'; + return undefined; + } + + private extractOs(ua: string): string | undefined { + if (ua.includes('Windows NT 10')) return 'Windows 10+'; + if (ua.includes('Windows NT 6.1')) return 'Windows 7'; + if (ua.includes('Windows')) return 'Windows'; + if (ua.includes('Mac OS X')) return 'macOS'; + if (ua.includes('Linux')) return 'Linux'; + if (ua.includes('Android')) return 'Android'; + if (ua.includes('iPhone') || ua.includes('iPad')) return 'iOS'; + return undefined; + } + + private extractDeviceType(ua: string): string | undefined { + if (ua.includes('Mobile') || ua.includes('Android')) return 'mobile'; + if (ua.includes('iPad') || ua.includes('Tablet')) return 'tablet'; + return 'desktop'; + } + + /** + * Simple geo-location lookup from IP address + * Returns raw IP-based location info or falls back to basic data + */ + private lookupGeoFromIp(ipAddress?: string): { country?: string; city?: string; region?: string } | null { + if (!ipAddress) return null; + if (ipAddress === '127.0.0.1' || ipAddress === '::1' || ipAddress === '::ffff:127.0.0.1') { + return { country: 'Local', city: 'Localhost', region: 'Local' }; + } + return { country: 'Unknown', city: 'Unknown', region: 'Unknown' }; + } + /** * Map Prisma session to DTO */ - private mapSessionToDto(session: any): SessionDto { + private mapSessionToDto(session: any, isCurrent: boolean = false): SessionDto { + const deviceInfo = session.deviceInfo as any; + const geoLocation = session.geoLocation as any; return { id: session.id, accessTokenJti: session.accessTokenJti, refreshTokenJti: session.refreshTokenJti, ipAddress: session.ipAddress, userAgent: session.userAgent, + displayName: session.displayName, + deviceInfo: deviceInfo || undefined, + geoLocation: geoLocation || undefined, isRevoked: session.isRevoked, expiresAt: session.expiresAt, createdAt: session.createdAt, lastActivityAt: session.lastActivityAt, revokedAt: session.revokedAt, + isCurrent, }; } } diff --git a/src/trust-score/dto/trust-score.dto.ts b/src/trust-score/dto/trust-score.dto.ts index 4682ee3a..1a888403 100644 --- a/src/trust-score/dto/trust-score.dto.ts +++ b/src/trust-score/dto/trust-score.dto.ts @@ -7,14 +7,10 @@ export class ScoreFactor { } export class TrustScoreBreakdownDto { - accountAge: ScoreFactor; - emailVerification: ScoreFactor; - twoFactorAuth: ScoreFactor; - profileCompleteness: ScoreFactor; - transactionHistory: ScoreFactor; - propertyListings: ScoreFactor; - apiKeyUsage: ScoreFactor; - passwordSecurity: ScoreFactor; + emailVerified: ScoreFactor; + idVerified: ScoreFactor; + completedTransactions: ScoreFactor; + activityDecay: ScoreFactor; totalScore: number; totalMaxScore: number; } diff --git a/src/trust-score/trust-score.service.spec.ts b/src/trust-score/trust-score.service.spec.ts index a6298faf..aecdc89c 100644 --- a/src/trust-score/trust-score.service.spec.ts +++ b/src/trust-score/trust-score.service.spec.ts @@ -19,6 +19,7 @@ describe('TrustScoreService', () => { lastTrustScoreUpdate: new Date(), createdAt: new Date('2023-01-01'), updatedAt: new Date(), + lastActivityAt: new Date(), properties: [ { id: 'prop-1', status: 'ACTIVE' }, { id: 'prop-2', status: 'ACTIVE' }, @@ -50,6 +51,9 @@ describe('TrustScoreService', () => { update: jest.fn(), findMany: jest.fn(), }, + verificationDocument: { + findFirst: jest.fn(), + }, } as any; beforeEach(async () => { @@ -78,6 +82,7 @@ describe('TrustScoreService', () => { describe('calculateTrustScore', () => { it('should calculate trust score for a user', async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.verificationDocument.findFirst.mockResolvedValue(null); mockPrismaService.user.update.mockResolvedValue({ ...mockUser, trustScore: 75, @@ -90,48 +95,28 @@ describe('TrustScoreService', () => { userId: 'user-123', score: expect.any(Number), breakdown: expect.objectContaining({ - accountAge: expect.objectContaining({ - score: expect.any(Number), + emailVerified: expect.objectContaining({ + score: 10, maxScore: 10, - percentage: expect.any(Number), - }), - emailVerification: expect.objectContaining({ - score: 5, - maxScore: 5, percentage: 100, }), - twoFactorAuth: expect.objectContaining({ - score: 5, - maxScore: 5, - percentage: 100, - }), - profileCompleteness: expect.objectContaining({ - score: expect.any(Number), - maxScore: 15, - percentage: expect.any(Number), - }), - transactionHistory: expect.objectContaining({ + idVerified: expect.objectContaining({ score: expect.any(Number), - maxScore: 25, + maxScore: 20, percentage: expect.any(Number), }), - propertyListings: expect.objectContaining({ + completedTransactions: expect.objectContaining({ score: expect.any(Number), - maxScore: 15, + maxScore: 45, percentage: expect.any(Number), }), - apiKeyUsage: expect.objectContaining({ + activityDecay: expect.objectContaining({ score: expect.any(Number), - maxScore: 10, - percentage: expect.any(Number), - }), - passwordSecurity: expect.objectContaining({ - score: expect.any(Number), - maxScore: 10, + maxScore: expect.any(Number), percentage: expect.any(Number), }), - totalScore: 0, - totalMaxScore: 95, + totalScore: expect.any(Number), + totalMaxScore: 75, }), lastUpdated: expect.any(Date), nextUpdateTime: expect.any(Date), @@ -155,24 +140,21 @@ describe('TrustScoreService', () => { describe('getTrustScore', () => { it('should return cached score if no refresh needed', async () => { - const recentUpdate = new Date(Date.now() - 12 * 3600000); // 12 hours ago + const recentUpdate = new Date(Date.now() - 12 * 3600000); const userWithRecentUpdate = { ...mockUser, lastTrustScoreUpdate: recentUpdate, }; mockPrismaService.user.findUnique.mockResolvedValue(userWithRecentUpdate); + mockPrismaService.verificationDocument.findFirst.mockResolvedValue(null); jest.spyOn(service, 'getScoreBreakdown').mockResolvedValue({ - accountAge: { score: 8, maxScore: 10, percentage: 80 }, - emailVerification: { score: 5, maxScore: 5, percentage: 100 }, - twoFactorAuth: { score: 5, maxScore: 5, percentage: 100 }, - profileCompleteness: { score: 12, maxScore: 15, percentage: 80 }, - transactionHistory: { score: 10, maxScore: 25, percentage: 40 }, - propertyListings: { score: 7, maxScore: 15, percentage: 47 }, - apiKeyUsage: { score: 5, maxScore: 10, percentage: 50 }, - passwordSecurity: { score: 10, maxScore: 10, percentage: 100 }, - totalScore: 0, - totalMaxScore: 95, + emailVerified: { score: 10, maxScore: 10, percentage: 100 }, + idVerified: { score: 0, maxScore: 20, percentage: 0 }, + completedTransactions: { score: 45, maxScore: 45, percentage: 100 }, + activityDecay: { score: 55, maxScore: 55, percentage: 100 }, + totalScore: 55, + totalMaxScore: 75, }); const result = await service.getTrustScore('user-123', false); @@ -206,44 +188,25 @@ describe('TrustScoreService', () => { describe('getScoreBreakdown', () => { it('should return detailed breakdown', async () => { mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.verificationDocument.findFirst.mockResolvedValue(null); const breakdown = await service.getScoreBreakdown('user-123'); expect(breakdown).toEqual({ - accountAge: expect.objectContaining({ - score: expect.any(Number), - maxScore: 10, - percentage: expect.any(Number), - }), - emailVerification: { score: 5, maxScore: 5, percentage: 100 }, - twoFactorAuth: { score: 5, maxScore: 5, percentage: 100 }, - profileCompleteness: expect.objectContaining({ - score: expect.any(Number), - maxScore: 15, - percentage: expect.any(Number), - }), - transactionHistory: expect.objectContaining({ - score: expect.any(Number), - maxScore: 25, - percentage: expect.any(Number), - }), - propertyListings: expect.objectContaining({ + emailVerified: { score: 10, maxScore: 10, percentage: 100 }, + idVerified: { score: 0, maxScore: 20, percentage: 0 }, + completedTransactions: expect.objectContaining({ score: expect.any(Number), - maxScore: 15, + maxScore: 45, percentage: expect.any(Number), }), - apiKeyUsage: expect.objectContaining({ + activityDecay: expect.objectContaining({ score: expect.any(Number), - maxScore: 10, + maxScore: expect.any(Number), percentage: expect.any(Number), }), - passwordSecurity: expect.objectContaining({ - score: expect.any(Number), - maxScore: 10, - percentage: expect.any(Number), - }), - totalScore: 0, - totalMaxScore: 95, + totalScore: expect.any(Number), + totalMaxScore: 75, }); }); }); @@ -282,41 +245,31 @@ describe('TrustScoreService', () => { }); }); - describe('calculateAccountAge', () => { - it('should calculate correct account age scores', async () => { - const oldUser = { + describe('decay algorithm', () => { + it('should apply decay for inactive users', async () => { + const inactiveUser = { ...mockUser, - createdAt: new Date('2022-01-01'), // Over 1 year old + lastActivityAt: new Date(Date.now() - 90 * 86400000), // 3 months inactive }; - mockPrismaService.user.findUnique.mockResolvedValue(oldUser); - - const breakdown = await service.getScoreBreakdown('user-123'); - expect(breakdown.accountAge.score).toBe(10); + mockPrismaService.user.findUnique.mockResolvedValue(inactiveUser); + mockPrismaService.verificationDocument.findFirst.mockResolvedValue(null); - const newUser = { - ...mockUser, - createdAt: new Date(Date.now() - 30 * 86400000), // 30 days old - }; - mockPrismaService.user.findUnique.mockResolvedValue(newUser); + const result = await service.calculateTrustScore('user-123'); - const newBreakdown = await service.getScoreBreakdown('user-123'); - expect(newBreakdown.accountAge.score).toBe(4); + expect(result.breakdown.activityDecay.percentage).toBeLessThan(100); }); }); - describe('calculateProfileCompleteness', () => { - it('should score profile completeness correctly', async () => { - const incompleteUser = { - ...mockUser, - firstName: 'John', - lastName: null, - phone: null, - avatar: null, - }; - mockPrismaService.user.findUnique.mockResolvedValue(incompleteUser); + describe('recalculateOnEvent', () => { + it('should recalculate on transaction completion', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser); + mockPrismaService.verificationDocument.findFirst.mockResolvedValue(null); + mockPrismaService.user.update.mockResolvedValue(mockUser); - const breakdown = await service.getScoreBreakdown('user-123'); - expect(breakdown.profileCompleteness.score).toBe(6); // firstName + email + const result = await service.recalculateOnEvent('user-123', 'TRANSACTION_COMPLETED'); + + expect(result).toBeDefined(); + expect(result.userId).toBe('user-123'); }); }); }); diff --git a/src/trust-score/trust-score.service.ts b/src/trust-score/trust-score.service.ts index 598e6eba..e3bd3951 100644 --- a/src/trust-score/trust-score.service.ts +++ b/src/trust-score/trust-score.service.ts @@ -5,14 +5,10 @@ import { PrismaService } from '../database/prisma.service'; import { UserData } from './types/user-data.interface'; export interface TrustScoreBreakdown { - accountAge: { score: number; maxScore: number; percentage: number }; - emailVerification: { score: number; maxScore: number; percentage: number }; - twoFactorAuth: { score: number; maxScore: number; percentage: number }; - profileCompleteness: { score: number; maxScore: number; percentage: number }; - transactionHistory: { score: number; maxScore: number; percentage: number }; - propertyListings: { score: number; maxScore: number; percentage: number }; - apiKeyUsage: { score: number; maxScore: number; percentage: number }; - passwordSecurity: { score: number; maxScore: number; percentage: number }; + emailVerified: { score: number; maxScore: number; percentage: number }; + idVerified: { score: number; maxScore: number; percentage: number }; + completedTransactions: { score: number; maxScore: number; percentage: number }; + activityDecay: { score: number; maxScore: number; percentage: number }; totalScore: number; totalMaxScore: number; } @@ -28,7 +24,8 @@ export interface TrustScoreResult { @Injectable() export class TrustScoreService { private readonly logger = new Logger(TrustScoreService.name); - private readonly updateIntervalHours = 24; // Recalculate daily + private readonly updateIntervalHours = 24; + private readonly DECAY_RATE_PER_MONTH = 0.10; constructor(private prisma: PrismaService) {} @@ -39,11 +36,8 @@ export class TrustScoreService { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { - properties: true, buyerTransactions: true, sellerTransactions: true, - apiKeys: true, - passwordHistory: true, }, }); @@ -54,7 +48,6 @@ export class TrustScoreService { const breakdown = await this.calculateBreakdown(user); const totalScore = this.calculateTotalScore(breakdown); - // Update user's trust score in database await this.prisma.user.update({ where: { id: userId }, data: { @@ -87,7 +80,6 @@ export class TrustScoreService { throw new Error('User not found'); } - // Check if score needs refresh const shouldRefresh = forceRefresh || !user.lastTrustScoreUpdate || this.isUpdateNeeded(user.lastTrustScoreUpdate); @@ -95,7 +87,6 @@ export class TrustScoreService { return this.calculateTrustScore(userId); } - // Return cached score with breakdown const breakdown = await this.getScoreBreakdown(userId); const nextUpdateTime = new Date(user.lastTrustScoreUpdate || new Date()); nextUpdateTime.setHours(nextUpdateTime.getHours() + this.updateIntervalHours); @@ -116,11 +107,8 @@ export class TrustScoreService { const user = await this.prisma.user.findUnique({ where: { id: userId }, include: { - properties: true, buyerTransactions: true, sellerTransactions: true, - apiKeys: true, - passwordHistory: true, }, }); @@ -131,192 +119,93 @@ export class TrustScoreService { return this.calculateBreakdown(user); } + /** + * Recalculate trust score after relevant events (transaction completion, verification) + */ + async recalculateOnEvent(userId: string, eventType: string): Promise { + this.logger.log(`Recalculating trust score for user ${userId} due to event: ${eventType}`); + return this.calculateTrustScore(userId); + } + /** * Calculate individual score components + * Factors: + * - email_verified: 10 pts + * - id_verified: 20 pts + * - completed_transactions: 15 pts each (max 3 = 45 pts) + * - activity_decay: -10% per month of inactivity */ - private async calculateBreakdown(user: UserData): Promise { - const accountAgeScore = this.calculateAccountAge(user.createdAt); - const emailVerificationScore = user.isVerified ? 5 : 0; - const twoFactorScore = user.twoFactorEnabled ? 5 : 0; - const profileCompletenessScore = this.calculateProfileCompleteness(user); - const transactionHistoryScore = await this.calculateTransactionHistory(user); - const propertyListingsScore = this.calculatePropertyListings(user); - const apiKeyUsageScore = this.calculateApiKeyUsage(user.apiKeys); - const passwordSecurityScore = this.calculatePasswordSecurity(user.passwordHistory); + private async calculateBreakdown(user: any): Promise { + const emailVerifiedScore = user.isVerified ? 10 : 0; + + const idVerified = await this.prisma.verificationDocument.findFirst({ + where: { + userId: user.id, + status: 'APPROVED', + }, + }); + const idVerifiedScore = idVerified ? 20 : 0; + + const completedBuyer = user.buyerTransactions?.filter((t: any) => t.status === 'COMPLETED') || []; + const completedSeller = user.sellerTransactions?.filter((t: any) => t.status === 'COMPLETED') || []; + const totalCompleted = completedBuyer.length + completedSeller.length; + const cappedCompleted = Math.min(totalCompleted, 3); + const completedTransactionsScore = cappedCompleted * 15; + + const baseScore = emailVerifiedScore + idVerifiedScore + completedTransactionsScore; + + const decayPenalty = this.calculateDecayPenalty(user.lastActivityAt || user.updatedAt); + + const finalScore = Math.max(0, Math.round(baseScore * (1 - decayPenalty))); return { - accountAge: { - score: accountAgeScore, + emailVerified: { + score: emailVerifiedScore, maxScore: 10, - percentage: (accountAgeScore / 10) * 100, - }, - emailVerification: { - score: emailVerificationScore, - maxScore: 5, - percentage: (emailVerificationScore / 5) * 100, - }, - twoFactorAuth: { - score: twoFactorScore, - maxScore: 5, - percentage: (twoFactorScore / 5) * 100, - }, - profileCompleteness: { - score: profileCompletenessScore, - maxScore: 15, - percentage: (profileCompletenessScore / 15) * 100, - }, - transactionHistory: { - score: transactionHistoryScore, - maxScore: 25, - percentage: (transactionHistoryScore / 25) * 100, + percentage: (emailVerifiedScore / 10) * 100, }, - propertyListings: { - score: propertyListingsScore, - maxScore: 15, - percentage: (propertyListingsScore / 15) * 100, + idVerified: { + score: idVerifiedScore, + maxScore: 20, + percentage: (idVerifiedScore / 20) * 100, }, - apiKeyUsage: { - score: apiKeyUsageScore, - maxScore: 10, - percentage: (apiKeyUsageScore / 10) * 100, + completedTransactions: { + score: completedTransactionsScore, + maxScore: 45, + percentage: (completedTransactionsScore / 45) * 100, }, - passwordSecurity: { - score: passwordSecurityScore, - maxScore: 10, - percentage: (passwordSecurityScore / 10) * 100, + activityDecay: { + score: finalScore, + maxScore: baseScore, + percentage: baseScore > 0 ? (finalScore / baseScore) * 100 : 100, }, - totalScore: 0, - totalMaxScore: 95, + totalScore: finalScore, + totalMaxScore: 75, }; } /** - * Calculate account age score - * Newer accounts get lower score - */ - private calculateAccountAge(createdAt: Date): number { - const ageInDays = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24); - - // Scale: 0 days = 0 points, 365+ days = 10 points - if (ageInDays >= 365) return 10; - if (ageInDays >= 180) return 8; - if (ageInDays >= 90) return 6; - if (ageInDays >= 30) return 4; - if (ageInDays >= 7) return 2; - return 0; - } - - /** - * Calculate profile completeness score - */ - private calculateProfileCompleteness(user: UserData): number { - let score = 0; - - if (user.firstName) score += 3; - if (user.lastName) score += 3; - if (user.phone) score += 3; - if (user.avatar) score += 3; - if (user.email) score += 3; - - return Math.min(score, 15); - } - - /** - * Calculate transaction history score - */ - private async calculateTransactionHistory(user: UserData): Promise { - const completedTransactions = [ - ...user.buyerTransactions.filter((t) => t.status === 'COMPLETED'), - ...user.sellerTransactions.filter((t) => t.status === 'COMPLETED'), - ]; - - if (completedTransactions.length === 0) return 0; - - // Score based on transaction count and consistency - let score = 0; - if (completedTransactions.length >= 50) score = 25; - else if (completedTransactions.length >= 25) score = 20; - else if (completedTransactions.length >= 10) score = 15; - else if (completedTransactions.length >= 5) score = 10; - else if (completedTransactions.length >= 1) score = 5; - - return score; - } - - /** - * Calculate property listings score + * Calculate decay penalty based on months of inactivity. + * Decay rate: 10% per month of inactivity (capped at 90%). */ - private calculatePropertyListings(user: UserData): number { - if (!user.properties || user.properties.length === 0) return 0; - - const activeListings = user.properties.filter((p) => p.status === 'ACTIVE').length; - - let score = 0; - if (activeListings >= 20) score = 15; - else if (activeListings >= 10) score = 12; - else if (activeListings >= 5) score = 10; - else if (activeListings >= 2) score = 7; - else if (activeListings >= 1) score = 4; + private calculateDecayPenalty(lastActivityAt: Date | null): number { + if (!lastActivityAt) return 0; - return score; - } + const now = new Date(); + const diffMs = now.getTime() - new Date(lastActivityAt).getTime(); + const monthsInactive = diffMs / (1000 * 60 * 60 * 24 * 30.44); - /** - * Calculate API key usage score - */ - private calculateApiKeyUsage(apiKeys: UserData['apiKeys']): number { - if (!apiKeys || apiKeys.length === 0) return 0; - - // Score based on active, non-revoked API keys with recent usage - const activeKeys = apiKeys.filter( - (k) => !k.revokedAt && (!k.expiresAt || k.expiresAt > new Date()), - ); - - const recentlyUsedKeys = activeKeys.filter( - (k) => k.lastUsedAt && Date.now() - k.lastUsedAt.getTime() < 30 * 24 * 60 * 60 * 1000, // Last 30 days - ); - - if (recentlyUsedKeys.length >= 3) return 10; - if (recentlyUsedKeys.length === 2) return 7; - if (recentlyUsedKeys.length === 1) return 5; - return 0; - } + if (monthsInactive < 1) return 0; - /** - * Calculate password security score - */ - private calculatePasswordSecurity(passwordHistory: UserData['passwordHistory']): number { - if (!passwordHistory || passwordHistory.length === 0) return 0; - - // Recent password change is good - const latestPasswordChange = passwordHistory[0]; - const daysSinceChange = - (Date.now() - latestPasswordChange.createdAt.getTime()) / (1000 * 60 * 60 * 24); - - // Score based on password update frequency - if (daysSinceChange <= 90) return 10; - if (daysSinceChange <= 180) return 8; - if (daysSinceChange <= 365) return 6; - if (daysSinceChange <= 730) return 4; - return 2; + const penalty = Math.min(monthsInactive * this.DECAY_RATE_PER_MONTH, 0.90); + return penalty; } /** * Calculate total trust score */ private calculateTotalScore(breakdown: TrustScoreBreakdown): number { - const total = - breakdown.accountAge.score + - breakdown.emailVerification.score + - breakdown.twoFactorAuth.score + - breakdown.profileCompleteness.score + - breakdown.transactionHistory.score + - breakdown.propertyListings.score + - breakdown.apiKeyUsage.score + - breakdown.passwordSecurity.score; - - // Convert to 0-100 scale - return Math.round((total / breakdown.totalMaxScore) * 100); + return breakdown.totalScore; } /** diff --git a/test/e2e/users-profile.e2e.spec.ts b/test/e2e/users-profile.e2e.spec.ts index 68845bda..3381feef 100644 --- a/test/e2e/users-profile.e2e.spec.ts +++ b/test/e2e/users-profile.e2e.spec.ts @@ -2,6 +2,7 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; import { PrismaService } from '../../src/database/prisma.service'; +import { ConfigService } from '@nestjs/config'; import { UsersController } from '../../src/users/users.controller'; import { UsersService } from '../../src/users/users.service'; import { ActivityLogService } from '../../src/users/activity-log.service'; @@ -118,6 +119,12 @@ describe('User profile e2e', () => { ActivityLogService, SessionsService, { provide: PrismaService, useValue: fakePrisma as any }, + { + provide: ConfigService, + useValue: { + get: (key: string, defaultValue?: any) => defaultValue, + }, + }, { provide: AuthService, useValue: { diff --git a/test/sessions/sessions.service.spec.ts b/test/sessions/sessions.service.spec.ts index b86c4efa..12471002 100644 --- a/test/sessions/sessions.service.spec.ts +++ b/test/sessions/sessions.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SessionsService } from '../../src/sessions/sessions.service'; import { PrismaService } from '../../src/database/prisma.service'; +import { ConfigService } from '@nestjs/config'; describe('SessionsService', () => { let service: SessionsService; @@ -19,7 +20,7 @@ describe('SessionsService', () => { beforeEach(async () => { jest.clearAllMocks(); const module: TestingModule = await Test.createTestingModule({ - providers: [SessionsService, { provide: PrismaService, useValue: mockPrismaService }], + providers: [SessionsService, { provide: PrismaService, useValue: mockPrismaService }, { provide: ConfigService, useValue: { get: (key: string, defaultValue?: any) => defaultValue } }], }).compile(); service = module.get(SessionsService);