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
Empty file added .codex
Empty file.
89 changes: 89 additions & 0 deletions prisma/migrations/20260424010000_add_fraud_detection/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
CREATE TYPE "FraudSeverity" AS ENUM ('LOW', 'MEDIUM', 'HIGH', 'CRITICAL');
CREATE TYPE "FraudStatus" AS ENUM ('OPEN', 'INVESTIGATING', 'RESOLVED', 'DISMISSED');
CREATE TYPE "FraudPattern" AS ENUM (
'EXCESSIVE_FAILED_LOGINS',
'SHARED_IP_MULTIPLE_ACCOUNTS',
'MULTIPLE_IPS_FOR_ACCOUNT',
'NEW_DEVICE_LOGIN',
'TOKEN_REUSE',
'RAPID_PROPERTY_LISTINGS',
'DUPLICATE_PROPERTY_ADDRESS',
'HIGH_VALUE_NEW_ACCOUNT_LISTING'
);

CREATE TABLE "fraud_alerts" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"property_id" TEXT,
"transaction_id" TEXT,
"session_id" TEXT,
"pattern" "FraudPattern" NOT NULL,
"severity" "FraudSeverity" NOT NULL,
"status" "FraudStatus" NOT NULL DEFAULT 'OPEN',
"score" INTEGER NOT NULL DEFAULT 0,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"evidence" JSONB,
"auto_blocked" BOOLEAN NOT NULL DEFAULT false,
"first_detected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_detected_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"occurrence_count" INTEGER NOT NULL DEFAULT 1,
"assigned_to_id" TEXT,
"resolved_by_id" TEXT,
"resolved_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fraud_alerts_pkey" PRIMARY KEY ("id")
);

CREATE TABLE "fraud_investigation_notes" (
"id" TEXT NOT NULL,
"alert_id" TEXT NOT NULL,
"actor_id" TEXT,
"action" TEXT,
"note" TEXT NOT NULL,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "fraud_investigation_notes_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "fraud_alerts_status_severity_idx" ON "fraud_alerts"("status", "severity");
CREATE INDEX "fraud_alerts_user_id_status_idx" ON "fraud_alerts"("user_id", "status");
CREATE INDEX "fraud_alerts_property_id_status_idx" ON "fraud_alerts"("property_id", "status");
CREATE INDEX "fraud_alerts_pattern_status_idx" ON "fraud_alerts"("pattern", "status");
CREATE INDEX "fraud_alerts_last_detected_at_idx" ON "fraud_alerts"("last_detected_at");
CREATE INDEX "fraud_investigation_notes_alert_id_created_at_idx"
ON "fraud_investigation_notes"("alert_id", "created_at");
CREATE INDEX "fraud_investigation_notes_actor_id_idx" ON "fraud_investigation_notes"("actor_id");

ALTER TABLE "fraud_alerts"
ADD CONSTRAINT "fraud_alerts_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "fraud_alerts"
ADD CONSTRAINT "fraud_alerts_property_id_fkey"
FOREIGN KEY ("property_id") REFERENCES "properties"("id") ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "fraud_alerts"
ADD CONSTRAINT "fraud_alerts_transaction_id_fkey"
FOREIGN KEY ("transaction_id") REFERENCES "transactions"("id") ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "fraud_alerts"
ADD CONSTRAINT "fraud_alerts_session_id_fkey"
FOREIGN KEY ("session_id") REFERENCES "sessions"("id") ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "fraud_alerts"
ADD CONSTRAINT "fraud_alerts_assigned_to_id_fkey"
FOREIGN KEY ("assigned_to_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "fraud_alerts"
ADD CONSTRAINT "fraud_alerts_resolved_by_id_fkey"
FOREIGN KEY ("resolved_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

ALTER TABLE "fraud_investigation_notes"
ADD CONSTRAINT "fraud_investigation_notes_alert_id_fkey"
FOREIGN KEY ("alert_id") REFERENCES "fraud_alerts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

ALTER TABLE "fraud_investigation_notes"
ADD CONSTRAINT "fraud_investigation_notes_actor_id_fkey"
FOREIGN KEY ("actor_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
150 changes: 119 additions & 31 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,31 @@ enum VerificationStatus {
REJECTED
}

enum FraudSeverity {
LOW
MEDIUM
HIGH
CRITICAL
}

enum FraudStatus {
OPEN
INVESTIGATING
RESOLVED
DISMISSED
}

enum FraudPattern {
EXCESSIVE_FAILED_LOGINS
SHARED_IP_MULTIPLE_ACCOUNTS
MULTIPLE_IPS_FOR_ACCOUNT
NEW_DEVICE_LOGIN
TOKEN_REUSE
RAPID_PROPERTY_LISTINGS
DUPLICATE_PROPERTY_ADDRESS
HIGH_VALUE_NEW_ACCOUNT_LISTING
}

// User model
model User {
id String @id @default(uuid())
Expand Down Expand Up @@ -100,21 +125,25 @@ model User {
referredById String? @map("referred_by_id")

// Relations
properties Property[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
sellerTransactions Transaction[] @relation("SellerTransactions")
documents Document[]
apiKeys ApiKey[]
passwordHistory PasswordHistory[]
blacklistedTokens BlacklistedToken[]
preferences UserPreferences?
activityLogs ActivityLog[]
verificationDocuments VerificationDocument[]
sessions Session[]
passwordResetTokens PasswordResetToken[]
loginHistory LoginHistory[]
referrals User[] @relation("Referrals")
referredBy User? @relation("Referrals", fields: [referredById], references: [id])
properties Property[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
sellerTransactions Transaction[] @relation("SellerTransactions")
documents Document[]
apiKeys ApiKey[]
passwordHistory PasswordHistory[]
blacklistedTokens BlacklistedToken[]
preferences UserPreferences?
activityLogs ActivityLog[]
verificationDocuments VerificationDocument[]
sessions Session[]
passwordResetTokens PasswordResetToken[]
loginHistory LoginHistory[]
fraudAlerts FraudAlert[] @relation("FraudAlertSubjectUser")
assignedFraudAlerts FraudAlert[] @relation("FraudAlertAssignedTo")
resolvedFraudAlerts FraudAlert[] @relation("FraudAlertResolvedBy")
fraudInvestigationNotes FraudInvestigationNote[]
referrals User[] @relation("Referrals")
referredBy User? @relation("Referrals", fields: [referredById], references: [id])

@@index([email])
@@index([role])
Expand All @@ -125,18 +154,18 @@ model User {
}

model ApiKey {
id String @id @default(uuid())
userId String @map("user_id")
name String
keyPrefix String @map("key_prefix")
keyHash String @unique @map("key_hash")
permissions String[] @default([])
usageCount Int @default(0) @map("usage_count")
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid())
userId String @map("user_id")
name String
keyPrefix String @map("key_prefix")
keyHash String @unique @map("key_hash")
permissions String[] @default([])
usageCount Int @default(0) @map("usage_count")
lastUsedAt DateTime? @map("last_used_at")
expiresAt DateTime? @map("expires_at")
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

Expand Down Expand Up @@ -255,6 +284,7 @@ model Property {
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
transactions Transaction[]
documents Document[]
fraudAlerts FraudAlert[]

@@index([ownerId])
@@index([status])
Expand All @@ -280,9 +310,10 @@ model Transaction {
updatedAt DateTime @updatedAt @map("updated_at")

// Relations
property Property @relation(fields: [propertyId], references: [id])
buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id])
seller User @relation("SellerTransactions", fields: [sellerId], references: [id])
property Property @relation(fields: [propertyId], references: [id])
buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id])
seller User @relation("SellerTransactions", fields: [sellerId], references: [id])
fraudAlerts FraudAlert[]

@@index([propertyId])
@@index([buyerId])
Expand Down Expand Up @@ -389,14 +420,71 @@ model Session {
lastActivityAt DateTime @default(now()) @map("last_activity_at")

// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
fraudAlerts FraudAlert[]

@@index([userId])
@@index([expiresAt])
@@index([isRevoked])
@@map("sessions")
}

model FraudAlert {
id String @id @default(uuid())
userId String? @map("user_id")
propertyId String? @map("property_id")
transactionId String? @map("transaction_id")
sessionId String? @map("session_id")
pattern FraudPattern
severity FraudSeverity
status FraudStatus @default(OPEN)
score Int @default(0)
title String
description String @db.Text
evidence Json?
autoBlocked Boolean @default(false) @map("auto_blocked")
firstDetectedAt DateTime @default(now()) @map("first_detected_at")
lastDetectedAt DateTime @default(now()) @map("last_detected_at")
occurrenceCount Int @default(1) @map("occurrence_count")
assignedToId String? @map("assigned_to_id")
resolvedById String? @map("resolved_by_id")
resolvedAt DateTime? @map("resolved_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User? @relation("FraudAlertSubjectUser", fields: [userId], references: [id], onDelete: SetNull)
property Property? @relation(fields: [propertyId], references: [id], onDelete: SetNull)
transaction Transaction? @relation(fields: [transactionId], references: [id], onDelete: SetNull)
session Session? @relation(fields: [sessionId], references: [id], onDelete: SetNull)
assignedTo User? @relation("FraudAlertAssignedTo", fields: [assignedToId], references: [id], onDelete: SetNull)
resolvedBy User? @relation("FraudAlertResolvedBy", fields: [resolvedById], references: [id], onDelete: SetNull)
notes FraudInvestigationNote[]

@@index([status, severity])
@@index([userId, status])
@@index([propertyId, status])
@@index([pattern, status])
@@index([lastDetectedAt])
@@map("fraud_alerts")
}

model FraudInvestigationNote {
id String @id @default(uuid())
alertId String @map("alert_id")
actorId String? @map("actor_id")
action String?
note String @db.Text
metadata Json?
createdAt DateTime @default(now()) @map("created_at")

alert FraudAlert @relation(fields: [alertId], references: [id], onDelete: Cascade)
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)

@@index([alertId, createdAt])
@@index([actorId])
@@map("fraud_investigation_notes")
}

// Verification Document model
model VerificationDocument {
id String @id @default(uuid())
Expand Down
56 changes: 56 additions & 0 deletions src/admin/admin.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,15 @@ import { AuthUserPayload } from '../auth/types/auth-user.type';
import { UserRole } from '../types/prisma.types';
import { AdminService } from './admin.service';
import {
AddFraudInvestigationNoteDto,
AdminUpdateUserDto,
AdminUsersQueryDto,
BlockFraudUserDto,
BulkModerationDto,
FlagPropertyDto,
FraudAlertsQueryDto,
ModerationQueueQueryDto,
ReviewFraudAlertDto,
TransactionMonitoringQueryDto,
} from './dto/admin.dto';

Expand Down Expand Up @@ -80,4 +84,56 @@ export class AdminController {
monitorTransactionsSummary() {
return this.adminService.transactionMonitoringSummary();
}

@Get('fraud/alerts')
listFraudAlerts(@Query() query: FraudAlertsQueryDto) {
return this.adminService.listFraudAlerts(query);
}

@Get('fraud/alerts/summary')
getFraudAlertsSummary() {
return this.adminService.getFraudAlertsSummary();
}

@Get('fraud/alerts/:id')
getFraudAlertDetails(@Param('id') alertId: string) {
return this.adminService.getFraudAlertDetails(alertId);
}

@Patch('fraud/alerts/:id')
reviewFraudAlert(
@Param('id') alertId: string,
@Body() payload: ReviewFraudAlertDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.adminService.reviewFraudAlert(alertId, payload, user.sub);
}

@Post('fraud/alerts/:id/notes')
addFraudAlertNote(
@Param('id') alertId: string,
@Body() payload: AddFraudInvestigationNoteDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.adminService.addFraudAlertNote(alertId, payload, user.sub);
}

@Post('fraud/alerts/:id/block-user')
blockFraudUser(
@Param('id') alertId: string,
@Body() payload: BlockFraudUserDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.adminService.blockFraudUser(alertId, user.sub, payload);
}

@Post('fraud/users/:id/scan')
scanUserForFraud(@Param('id') userId: string, @CurrentUser() user: AuthUserPayload) {
return this.adminService.scanUserForFraud(userId, user.sub);
}

@Post('fraud/properties/:id/scan')
scanPropertyForFraud(@Param('id') propertyId: string, @CurrentUser() user: AuthUserPayload) {
return this.adminService.scanPropertyForFraud(propertyId, user.sub);
}
}
3 changes: 2 additions & 1 deletion src/admin/admin.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
import { AdminService } from './admin.service';
import { PrismaModule } from '../database/prisma.module';
import { FraudModule } from '../fraud/fraud.module';

@Module({
imports: [PrismaModule],
imports: [PrismaModule, FraudModule],
controllers: [AdminController],
providers: [AdminService],
exports: [AdminService],
Expand Down
Loading
Loading