mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 17:38:12 +00:00
Refactored the whole app based on the requirements shared
This commit is contained in:
74
apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts
Normal file
74
apps/edr-passenger-api/src/modules/fraud/fraud.controller.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Controller, Get, Post, Body, Query, UseGuards, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { FraudService, FraudRuleConfig } from './fraud.service';
|
||||
import { IamGuard, IamRoles } from '../../common/iam-adapter';
|
||||
import { UserRole } from '@prisma/client';
|
||||
|
||||
@ApiTags('Fraud Detection')
|
||||
@Controller('fraud')
|
||||
@UseGuards(IamGuard)
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
export class FraudController {
|
||||
private readonly logger = new Logger(FraudController.name);
|
||||
|
||||
constructor(private fraudService: FraudService) {}
|
||||
|
||||
/**
|
||||
* Get fraud alerts
|
||||
*/
|
||||
@Get('alerts')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Get fraud alerts' })
|
||||
async getAlerts(
|
||||
@Query('userId') userId?: string,
|
||||
@Query('limit') limit?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
const alerts = await this.fraudService.getAlerts(userId, parseInt(limit || '100'), parseInt(offset || '0'));
|
||||
return { data: alerts, total: alerts.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fraud rules
|
||||
*/
|
||||
@Get('rules')
|
||||
@IamRoles('ADMIN')
|
||||
@ApiOperation({ summary: 'Get fraud detection rules' })
|
||||
async getRules() {
|
||||
const rules = await this.fraudService.getRules();
|
||||
return { data: rules };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update fraud rule
|
||||
*/
|
||||
@Post('rules')
|
||||
@IamRoles('ADMIN')
|
||||
@ApiOperation({ summary: 'Create or update fraud rule' })
|
||||
async upsertRule(@Body() body: { type: string; config: FraudRuleConfig }) {
|
||||
const rule = await this.fraudService.upsertRule(body.type, body.config);
|
||||
return { data: rule, message: 'Rule updated successfully' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user temporarily
|
||||
*/
|
||||
@Post('actions/block')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Block user temporarily' })
|
||||
async blockUser(@Body() body: { userId: string; durationMinutes: number }) {
|
||||
await this.fraudService.blockUserTemporarily(body.userId, body.durationMinutes);
|
||||
return { message: `User blocked for ${body.durationMinutes} minutes` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user
|
||||
*/
|
||||
@Post('actions/unblock')
|
||||
@IamRoles('ADMIN', 'SUPERVISOR')
|
||||
@ApiOperation({ summary: 'Unblock user' })
|
||||
async unblockUser(@Body() body: { userId: string }) {
|
||||
await this.fraudService.unblockUser(body.userId);
|
||||
return { message: 'User unblocked' };
|
||||
}
|
||||
}
|
||||
12
apps/edr-passenger-api/src/modules/fraud/fraud.module.ts
Normal file
12
apps/edr-passenger-api/src/modules/fraud/fraud.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { FraudService } from './fraud.service';
|
||||
import { FraudController } from './fraud.controller';
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule],
|
||||
providers: [FraudService],
|
||||
controllers: [FraudController],
|
||||
exports: [FraudService],
|
||||
})
|
||||
export class FraudModule {}
|
||||
252
apps/edr-passenger-api/src/modules/fraud/fraud.service.ts
Normal file
252
apps/edr-passenger-api/src/modules/fraud/fraud.service.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
|
||||
export interface FraudRuleConfig {
|
||||
type: 'VELOCITY' | 'HIGH_VALUE' | 'FAILED_PAYMENTS' | 'MULTIPLE_METHODS';
|
||||
enabled: boolean;
|
||||
threshold: number;
|
||||
timeWindowMinutes?: number;
|
||||
blockDurationMinutes?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FraudService {
|
||||
private readonly logger = new Logger(FraudService.name);
|
||||
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Evaluate fraud rules and create alerts if triggered
|
||||
*/
|
||||
async evaluateRules(
|
||||
userId: string,
|
||||
eventType: 'booking.created' | 'payment.failed' | 'auth.login.failed',
|
||||
context: Record<string, unknown>,
|
||||
): Promise<{ triggered: boolean; rules: string[] }> {
|
||||
const triggeredRules: string[] = [];
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
|
||||
if (!user) return { triggered: false, rules: [] };
|
||||
|
||||
// Check velocity rule (multiple bookings in short time)
|
||||
if (eventType === 'booking.created') {
|
||||
const velocityTriggered = await this.checkVelocityRule(userId);
|
||||
if (velocityTriggered) {
|
||||
triggeredRules.push('VELOCITY');
|
||||
}
|
||||
|
||||
// Check high-value booking
|
||||
const amount = (context.amountMinor as number) || 0;
|
||||
const highValueTriggered = await this.checkHighValueRule(amount);
|
||||
if (highValueTriggered) {
|
||||
triggeredRules.push('HIGH_VALUE');
|
||||
}
|
||||
}
|
||||
|
||||
// Check repeated failed payments
|
||||
if (eventType === 'payment.failed') {
|
||||
const failedPaymentTriggered = await this.checkFailedPaymentRule(userId);
|
||||
if (failedPaymentTriggered) {
|
||||
triggeredRules.push('FAILED_PAYMENTS');
|
||||
}
|
||||
}
|
||||
|
||||
// Create alert if rules triggered
|
||||
if (triggeredRules.length > 0) {
|
||||
await this.createFraudAlert(userId, eventType, triggeredRules, context);
|
||||
return { triggered: true, rules: triggeredRules };
|
||||
}
|
||||
|
||||
return { triggered: false, rules: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check velocity rule: X bookings in Y minutes
|
||||
*/
|
||||
private async checkVelocityRule(userId: string): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'VELOCITY', enabled: true },
|
||||
});
|
||||
|
||||
if (!rule) return false;
|
||||
|
||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 30;
|
||||
const threshold = rule.threshold;
|
||||
|
||||
const bookingCount = await this.prisma.booking.count({
|
||||
where: {
|
||||
passengerId: userId,
|
||||
createdAt: {
|
||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return bookingCount > threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check high-value booking rule
|
||||
*/
|
||||
private async checkHighValueRule(amountMinor: number): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'HIGH_VALUE', enabled: true },
|
||||
});
|
||||
|
||||
if (!rule) return false;
|
||||
|
||||
// threshold is in ETB (convert minor units to ETB)
|
||||
const amountEtb = amountMinor / 100;
|
||||
return amountEtb > rule.threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check failed payment rule: X failed attempts in Y minutes
|
||||
*/
|
||||
private async checkFailedPaymentRule(userId: string): Promise<boolean> {
|
||||
const rule = await this.prisma.fraudRule.findFirst({
|
||||
where: { type: 'FAILED_PAYMENTS', enabled: true },
|
||||
});
|
||||
|
||||
if (!rule) return false;
|
||||
|
||||
const timeWindowMinutes = (rule.config as any)?.timeWindowMinutes || 60;
|
||||
const threshold = rule.threshold;
|
||||
|
||||
const failedCount = await this.prisma.paymentIntent.count({
|
||||
where: {
|
||||
booking: { passengerId: userId },
|
||||
status: 'FAILED',
|
||||
updatedAt: {
|
||||
gte: new Date(Date.now() - timeWindowMinutes * 60 * 1000),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return failedCount > threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fraud alert
|
||||
*/
|
||||
private async createFraudAlert(
|
||||
userId: string,
|
||||
eventType: string,
|
||||
triggeredRules: string[],
|
||||
context: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const alert = await this.prisma.fraudAlert.create({
|
||||
data: {
|
||||
userId,
|
||||
eventType,
|
||||
triggeredRules,
|
||||
context: context as any,
|
||||
severity: triggeredRules.length > 1 ? 'HIGH' : 'MEDIUM',
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.warn(`Fraud alert created: ${alert.id} for user ${userId} - rules: ${triggeredRules.join(', ')}`);
|
||||
|
||||
// Trigger blocking if needed
|
||||
if (triggeredRules.includes('HIGH_VALUE') || triggeredRules.length > 1) {
|
||||
await this.blockUserTemporarily(userId, 30); // Block for 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Block user temporarily
|
||||
*/
|
||||
async blockUserTemporarily(userId: string, durationMinutes: number): Promise<void> {
|
||||
const blockedUntil = new Date(Date.now() + durationMinutes * 60 * 1000);
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { blockedUntil },
|
||||
});
|
||||
this.logger.warn(`User ${userId} blocked until ${blockedUntil.toISOString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock user
|
||||
*/
|
||||
async unblockUser(userId: string): Promise<void> {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { blockedUntil: null },
|
||||
});
|
||||
this.logger.log(`User ${userId} unblocked`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fraud alerts
|
||||
*/
|
||||
async getAlerts(userId?: string, limit = 100, offset = 0) {
|
||||
return this.prisma.fraudAlert.findMany({
|
||||
where: userId ? { userId } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update a fraud rule
|
||||
*/
|
||||
async upsertRule(
|
||||
type: string,
|
||||
config: FraudRuleConfig,
|
||||
) {
|
||||
return this.prisma.fraudRule.upsert({
|
||||
where: { type: type as any },
|
||||
update: {
|
||||
enabled: config.enabled,
|
||||
threshold: config.threshold,
|
||||
config: config as any,
|
||||
},
|
||||
create: {
|
||||
type: type as any,
|
||||
enabled: config.enabled,
|
||||
threshold: config.threshold,
|
||||
config: config as any,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fraud rules
|
||||
*/
|
||||
async getRules() {
|
||||
return this.prisma.fraudRule.findMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for booking created
|
||||
*/
|
||||
@OnEvent('booking.created')
|
||||
async onBookingCreated(payload: { booking: any }) {
|
||||
await this.evaluateRules(payload.booking.passengerId, 'booking.created', {
|
||||
bookingId: payload.booking.id,
|
||||
amountMinor: payload.booking.totalMinor,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for payment failed
|
||||
*/
|
||||
@OnEvent('payment.failed')
|
||||
async onPaymentFailed(payload: { intentId: string; userId: string }) {
|
||||
await this.evaluateRules(payload.userId, 'payment.failed', {
|
||||
intentId: payload.intentId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Event listener for auth login failed
|
||||
*/
|
||||
@OnEvent('auth.login.failed')
|
||||
async onLoginFailed(payload: { userId: string; email: string }) {
|
||||
await this.evaluateRules(payload.userId, 'auth.login.failed', {
|
||||
email: payload.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user