Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
3 changes: 2 additions & 1 deletion src/fraud/fraud.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
})
Expand Down
2 changes: 2 additions & 0 deletions src/fraud/fraud.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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();

Expand Down
90 changes: 86 additions & 4 deletions src/fraud/fraud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>('FRAUD_ALERT_RECIPIENTS') ?? '')
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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(
Expand All @@ -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: `
<h2>Fraud Alert - ${payload.severity}</h2>
<p>${payload.description}</p>
<p>If you did not perform this action, please secure your account immediately and contact support.</p>
`,
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<string | null> {
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: {
Expand Down
138 changes: 128 additions & 10 deletions src/notifications/notifications.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -20,20 +23,25 @@ export class NotificationsGateway implements OnGatewayConnection, OnGatewayDisco
server: Server;

private logger: Logger = new Logger('NotificationsGateway');
private userSockets = new Map<string, string[]>(); // userId -> socketIds
private userSockets = new Map<string, string[]>();
private socketUsers = new Map<string, string>();

handleConnection(client: Socket) {
const userId = client.handshake.query.userId as string;
if (userId) {
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);
Expand All @@ -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);
}
}
33 changes: 33 additions & 0 deletions src/sessions/dto/session.dto.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
}
Loading
Loading