Skip to content
Open
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
154 changes: 154 additions & 0 deletions app/admin/risk/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
"use client"

import { useState, useEffect, useCallback } from "react"
import { RiskSummaryCards } from "@/components/admin/risk/RiskSummaryCards"
import { RiskFilterBar } from "@/components/admin/risk/RiskFilterBar"
import { RiskDataTable } from "@/components/admin/risk/RiskDataTable"
import { RiskDetailModal } from "@/components/admin/risk/RiskDetailModal"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { AlertCircle } from "lucide-react"

export default function AdminRiskDashboard() {
const [summary, setSummary] = useState<any>(null)
const [isSummaryLoading, setIsSummaryLoading] = useState(true)
const [summaryError, setSummaryError] = useState<string | null>(null)

const [details, setDetails] = useState<any[]>([])
const [isDetailsLoading, setIsDetailsLoading] = useState(false)
const [detailsError, setDetailsError] = useState<string | null>(null)

const [filters, setFilters] = useState({
type: "late_repayments",
status: "all",
role: "all",
dateRange: "",
})

const [selectedRecord, setSelectedRecord] = useState<any | null>(null)
const [isModalOpen, setIsModalOpen] = useState(false)

const fetchSummary = async () => {
try {
setIsSummaryLoading(true)
setSummaryError(null)
const res = await fetch("/api/admin/risk/summary")
if (!res.ok) {
if (res.status === 401 || res.status === 403) throw new Error("Unauthorized access.")
throw new Error("Failed to fetch risk summary.")
}
const data = await res.json()
setSummary(data.summary)
} catch (err: any) {
setSummaryError(err.message || "An error occurred.")
} finally {
setIsSummaryLoading(false)
}
}

const fetchDetails = useCallback(async () => {
try {
setIsDetailsLoading(true)
setDetailsError(null)

const queryParams = new URLSearchParams({
type: filters.type,
page: "1",
limit: "50",
})

if (filters.status !== "all") queryParams.append("status", filters.status)
if (filters.role !== "all") queryParams.append("role", filters.role)
if (filters.dateRange) queryParams.append("dateRange", filters.dateRange)

const res = await fetch(`/api/admin/risk/details?${queryParams.toString()}`)
if (!res.ok) {
if (res.status === 401 || res.status === 403) throw new Error("Unauthorized access.")
throw new Error("Failed to fetch risk details.")
}
const data = await res.json()
setDetails(data.data.records)
} catch (err: any) {
setDetailsError(err.message || "An error occurred.")
} finally {
setIsDetailsLoading(false)
}
}, [filters])

// Fetch summary on mount
useEffect(() => {
fetchSummary()
}, [])

// Fetch details whenever filters change
useEffect(() => {
fetchDetails()
}, [fetchDetails])

const handleFilterChange = (key: string, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}

const handleClearFilters = () => {
setFilters({
type: "late_repayments",
status: "all",
role: "all",
dateRange: "",
})
}

const handleRowClick = (record: any) => {
setSelectedRecord(record)
setIsModalOpen(true)
}

return (
<div className="container mx-auto py-8 space-y-8">
<div>
<h1 className="text-3xl font-bold tracking-tight">Risk Dashboard</h1>
<p className="text-muted-foreground mt-2">
Monitor and take action on system-wide risk signals and flagged entities.
</p>
</div>

{summaryError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading summary</AlertTitle>
<AlertDescription>{summaryError}</AlertDescription>
</Alert>
)}

<RiskSummaryCards summary={summary} isLoading={isSummaryLoading} />

<div className="space-y-4">
<h2 className="text-xl font-semibold tracking-tight">Risk Details</h2>
<RiskFilterBar
filters={filters}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
/>

{detailsError ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading details</AlertTitle>
<AlertDescription>{detailsError}</AlertDescription>
</Alert>
) : (
<RiskDataTable
data={details}
isLoading={isDetailsLoading}
onRowClick={handleRowClick}
/>
)}
</div>

<RiskDetailModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
record={selectedRecord}
/>
</div>
)
}
128 changes: 128 additions & 0 deletions app/api/admin/risk/details/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { NextResponse } from "next/server"

import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user"
import dbConnect from "@/lib/dbConnect"
import HirePurchaseContract from "@/models/HirePurchaseContract"
import Transaction from "@/models/Transaction"
import InvestmentPool from "@/models/InvestmentPool"
import User from "@/models/User" // In case population needs the model registered
import {
buildLateRepaymentsQuery,
buildRepeatedFailedTransactionsPipeline,
buildInactiveContractsQuery,
buildUnderperformingPoolsPipeline,
buildHighValueWalletFundingQuery,
} from "@/lib/risk-helpers"

export async function GET(request: Request) {
try {
const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

if (user.role !== "admin") {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

await dbConnect()

const { searchParams } = new URL(request.url)
const type = searchParams.get("type") // e.g., late_repayments
const page = parseInt(searchParams.get("page") || "1")
const limit = parseInt(searchParams.get("limit") || "10")
const skip = (page - 1) * limit

let records: any[] = []
let total = 0

// Construct options from search params for flexibility
const options: any = {}
if (searchParams.get("daysInactive")) options.daysInactive = parseInt(searchParams.get("daysInactive")!)
if (searchParams.get("threshold")) options.threshold = parseInt(searchParams.get("threshold")!)
if (searchParams.get("daysFrame")) options.daysFrame = parseInt(searchParams.get("daysFrame")!)
if (searchParams.get("suspiciousThresholdNgn")) options.suspiciousThresholdNgn = parseInt(searchParams.get("suspiciousThresholdNgn")!)

switch (type) {
case "late_repayments": {
const query = buildLateRepaymentsQuery(options)
total = await HirePurchaseContract.countDocuments(query)
records = await HirePurchaseContract.find(query)
.skip(skip)
.limit(limit)
.populate("driverUserId", "name email privyUserId")
.lean()
break
}
case "repeated_failed_transactions": {
const pipeline = buildRepeatedFailedTransactionsPipeline(options)

// Count via aggregation
const countPipeline = [...pipeline, { $count: "total" }]
const countResult = await Transaction.aggregate(countPipeline)
total = countResult[0]?.total || 0

// Fetch data
const dataPipeline = [...pipeline, { $skip: skip }, { $limit: limit }]
records = await Transaction.aggregate(dataPipeline)

// Populate User info (where _id is the grouped userId)
await User.populate(records, { path: "_id", select: "name email privyUserId" })
break
}
case "inactive_contracts": {
const query = buildInactiveContractsQuery(options)
total = await HirePurchaseContract.countDocuments(query)
records = await HirePurchaseContract.find(query)
.skip(skip)
.limit(limit)
.populate("driverUserId", "name email privyUserId")
.lean()
break
}
case "underperforming_pools": {
const pipeline = buildUnderperformingPoolsPipeline(options)

// Count
const countPipeline = [...pipeline, { $count: "total" }]
const countResult = await InvestmentPool.aggregate(countPipeline)
total = countResult[0]?.total || 0

// Data
const dataPipeline = [...pipeline, { $skip: skip }, { $limit: limit }]
records = await InvestmentPool.aggregate(dataPipeline)
break
}
case "high_value_wallet_funding": {
const query = buildHighValueWalletFundingQuery(options)
total = await Transaction.countDocuments(query)
records = await Transaction.find(query)
.skip(skip)
.limit(limit)
.populate("userId", "name email privyUserId")
.lean()
break
}
default:
return NextResponse.json({ message: "Invalid or missing risk type." }, { status: 400 })
}

const response = NextResponse.json({
success: true,
data: {
records,
pagination: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
},
})

return shouldRefreshSession ? withSessionRefresh(response, user) : response
} catch (error) {
console.error("ADMIN_RISK_DETAILS_ERROR", error)
return NextResponse.json({ message: "Failed to load risk details." }, { status: 500 })
}
}
57 changes: 57 additions & 0 deletions app/api/admin/risk/summary/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextResponse } from "next/server"

import { getAuthenticatedUser, withSessionRefresh } from "@/lib/auth/current-user"
import dbConnect from "@/lib/dbConnect"
import HirePurchaseContract from "@/models/HirePurchaseContract"
import Transaction from "@/models/Transaction"
import InvestmentPool from "@/models/InvestmentPool"
import {
buildLateRepaymentsQuery,
buildRepeatedFailedTransactionsPipeline,
buildInactiveContractsQuery,
buildUnderperformingPoolsPipeline,
buildHighValueWalletFundingQuery,
} from "@/lib/risk-helpers"

export async function GET(request: Request) {
try {
const { user, shouldRefreshSession } = await getAuthenticatedUser(request)
if (!user) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 })
}

if (user.role !== "admin") {
return NextResponse.json({ message: "Forbidden" }, { status: 403 })
}

await dbConnect()

const [
lateRepaymentsCount,
failedTransactionsResult,
inactiveContractsCount,
underperformingPoolsResult,
highValueFundingCount,
] = await Promise.all([
HirePurchaseContract.countDocuments(buildLateRepaymentsQuery()),
Transaction.aggregate(buildRepeatedFailedTransactionsPipeline()),
HirePurchaseContract.countDocuments(buildInactiveContractsQuery()),
InvestmentPool.aggregate(buildUnderperformingPoolsPipeline()),
Transaction.countDocuments(buildHighValueWalletFundingQuery()),
])

const summary = {
lateRepayments: lateRepaymentsCount,
repeatedFailedTransactions: failedTransactionsResult.length,
inactiveContracts: inactiveContractsCount,
underperformingPools: underperformingPoolsResult.length,
highValueWalletFundings: highValueFundingCount,
}

const response = NextResponse.json({ success: true, summary })
return shouldRefreshSession ? withSessionRefresh(response, user) : response
} catch (error) {
console.error("ADMIN_RISK_SUMMARY_ERROR", error)
return NextResponse.json({ message: "Failed to load risk summary." }, { status: 500 })
}
}
Loading
Loading