368 lines
9.8 KiB
TypeScript
368 lines
9.8 KiB
TypeScript
|
|
import { ContractStatus, ContractType } from "@prisma/client";
|
||
|
|
import { prisma } from "@/lib/db/prisma";
|
||
|
|
|
||
|
|
const TREND_WINDOW_DAYS = 30;
|
||
|
|
|
||
|
|
const STATUS_LABELS: Record<ContractStatus, string> = {
|
||
|
|
UPLOADED: "Uploaded",
|
||
|
|
PROCESSING: "Processing",
|
||
|
|
COMPLETED: "Analyzed",
|
||
|
|
FAILED: "Failed",
|
||
|
|
};
|
||
|
|
|
||
|
|
const TYPE_LABELS: Record<ContractType, string> = {
|
||
|
|
INSURANCE_AUTO: "Auto Insurance",
|
||
|
|
INSURANCE_HOME: "Home Insurance",
|
||
|
|
INSURANCE_HEALTH: "Health Insurance",
|
||
|
|
INSURANCE_LIFE: "Life Insurance",
|
||
|
|
LOAN: "Loan",
|
||
|
|
CREDIT_CARD: "Credit Card",
|
||
|
|
INVESTMENT: "Investment",
|
||
|
|
OTHER: "Other",
|
||
|
|
};
|
||
|
|
|
||
|
|
const toDateKey = (date: Date): string => {
|
||
|
|
const year = date.getFullYear();
|
||
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||
|
|
const day = String(date.getDate()).padStart(2, "0");
|
||
|
|
|
||
|
|
return `${year}-${month}-${day}`;
|
||
|
|
};
|
||
|
|
|
||
|
|
const formatTrendLabel = (date: Date): string =>
|
||
|
|
date.toLocaleDateString("en-US", {
|
||
|
|
month: "short",
|
||
|
|
day: "numeric",
|
||
|
|
});
|
||
|
|
|
||
|
|
const clamp = (value: number, min: number, max: number): number =>
|
||
|
|
Math.max(min, Math.min(max, value));
|
||
|
|
|
||
|
|
const countKeyPoints = (value: unknown): number => {
|
||
|
|
if (!value || typeof value !== "object") {
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
const candidate = value as {
|
||
|
|
guarantees?: unknown;
|
||
|
|
exclusions?: unknown;
|
||
|
|
importantDates?: unknown;
|
||
|
|
franchise?: unknown;
|
||
|
|
};
|
||
|
|
|
||
|
|
const guarantees = Array.isArray(candidate.guarantees)
|
||
|
|
? candidate.guarantees.length
|
||
|
|
: 0;
|
||
|
|
const exclusions = Array.isArray(candidate.exclusions)
|
||
|
|
? candidate.exclusions.length
|
||
|
|
: 0;
|
||
|
|
const importantDates = Array.isArray(candidate.importantDates)
|
||
|
|
? candidate.importantDates.length
|
||
|
|
: 0;
|
||
|
|
const franchise =
|
||
|
|
typeof candidate.franchise === "string" && candidate.franchise.trim()
|
||
|
|
? 1
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
return guarantees + exclusions + importantDates + franchise;
|
||
|
|
};
|
||
|
|
|
||
|
|
export async function getUserStats(clerkUserId: string) {
|
||
|
|
try {
|
||
|
|
const user = await prisma.user.findUnique({
|
||
|
|
where: { clerkId: clerkUserId },
|
||
|
|
select: { id: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user) {
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
stats: {
|
||
|
|
totalContracts: 0,
|
||
|
|
analyzedContracts: 0,
|
||
|
|
processingContracts: 0,
|
||
|
|
uploadedContracts: 0,
|
||
|
|
failedContracts: 0,
|
||
|
|
analysisRate: 0,
|
||
|
|
},
|
||
|
|
chartData: {
|
||
|
|
byType: [],
|
||
|
|
byStatus: [],
|
||
|
|
trends: [],
|
||
|
|
},
|
||
|
|
premiumInfo: {
|
||
|
|
averagePremium: 0,
|
||
|
|
totalPremium: 0,
|
||
|
|
count: 0,
|
||
|
|
},
|
||
|
|
aiLearningTelemetry: {
|
||
|
|
completedSamples: 0,
|
||
|
|
completedLast7Days: 0,
|
||
|
|
avgSummaryLength: 0,
|
||
|
|
avgExtractedTextLength: 0,
|
||
|
|
avgKeyPointsPerContract: 0,
|
||
|
|
learningScore: 0,
|
||
|
|
improvementHint:
|
||
|
|
"Analyze contracts to build your AI quality profile.",
|
||
|
|
},
|
||
|
|
recentContracts: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const today = new Date();
|
||
|
|
const trendStartDate = new Date(today);
|
||
|
|
trendStartDate.setHours(0, 0, 0, 0);
|
||
|
|
trendStartDate.setDate(trendStartDate.getDate() - (TREND_WINDOW_DAYS - 1));
|
||
|
|
|
||
|
|
const sevenDaysAgo = new Date(today);
|
||
|
|
sevenDaysAgo.setHours(0, 0, 0, 0);
|
||
|
|
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 6);
|
||
|
|
|
||
|
|
const [
|
||
|
|
totalContracts,
|
||
|
|
analyzedContracts,
|
||
|
|
processingContracts,
|
||
|
|
uploadedContracts,
|
||
|
|
failedContracts,
|
||
|
|
contractsByType,
|
||
|
|
contractsByStatus,
|
||
|
|
recentUploads,
|
||
|
|
premiumStats,
|
||
|
|
completedContractsForTelemetry,
|
||
|
|
completedLast7Days,
|
||
|
|
recentAnalyzedContracts,
|
||
|
|
] = await Promise.all([
|
||
|
|
prisma.contract.count({
|
||
|
|
where: { userId: user.id },
|
||
|
|
}),
|
||
|
|
prisma.contract.count({
|
||
|
|
where: { userId: user.id, status: "COMPLETED" },
|
||
|
|
}),
|
||
|
|
prisma.contract.count({
|
||
|
|
where: { userId: user.id, status: "PROCESSING" },
|
||
|
|
}),
|
||
|
|
prisma.contract.count({
|
||
|
|
where: { userId: user.id, status: "UPLOADED" },
|
||
|
|
}),
|
||
|
|
prisma.contract.count({
|
||
|
|
where: { userId: user.id, status: "FAILED" },
|
||
|
|
}),
|
||
|
|
prisma.contract.groupBy({
|
||
|
|
by: ["type"],
|
||
|
|
where: { userId: user.id },
|
||
|
|
_count: {
|
||
|
|
_all: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
prisma.contract.groupBy({
|
||
|
|
by: ["status"],
|
||
|
|
where: { userId: user.id },
|
||
|
|
_count: {
|
||
|
|
_all: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
prisma.contract.findMany({
|
||
|
|
where: {
|
||
|
|
userId: user.id,
|
||
|
|
createdAt: { gte: trendStartDate },
|
||
|
|
},
|
||
|
|
select: {
|
||
|
|
createdAt: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
prisma.contract.aggregate({
|
||
|
|
where: {
|
||
|
|
userId: user.id,
|
||
|
|
status: "COMPLETED",
|
||
|
|
premium: { not: null },
|
||
|
|
},
|
||
|
|
_avg: { premium: true },
|
||
|
|
_sum: { premium: true },
|
||
|
|
_count: {
|
||
|
|
_all: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
prisma.contract.findMany({
|
||
|
|
where: {
|
||
|
|
userId: user.id,
|
||
|
|
status: "COMPLETED",
|
||
|
|
},
|
||
|
|
orderBy: {
|
||
|
|
updatedAt: "desc",
|
||
|
|
},
|
||
|
|
take: 25,
|
||
|
|
select: {
|
||
|
|
summary: true,
|
||
|
|
extractedText: true,
|
||
|
|
keyPoints: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
prisma.contract.count({
|
||
|
|
where: {
|
||
|
|
userId: user.id,
|
||
|
|
status: "COMPLETED",
|
||
|
|
updatedAt: {
|
||
|
|
gte: sevenDaysAgo,
|
||
|
|
},
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
prisma.contract.findMany({
|
||
|
|
where: { userId: user.id, status: "COMPLETED" },
|
||
|
|
orderBy: { createdAt: "desc" },
|
||
|
|
take: 5,
|
||
|
|
select: {
|
||
|
|
id: true,
|
||
|
|
title: true,
|
||
|
|
type: true,
|
||
|
|
createdAt: true,
|
||
|
|
premium: true,
|
||
|
|
},
|
||
|
|
}),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const dailyUploads = new Map<string, number>();
|
||
|
|
for (const item of recentUploads) {
|
||
|
|
const dayKey = toDateKey(item.createdAt);
|
||
|
|
dailyUploads.set(dayKey, (dailyUploads.get(dayKey) ?? 0) + 1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const trends = Array.from({ length: TREND_WINDOW_DAYS }, (_, index) => {
|
||
|
|
const date = new Date(trendStartDate);
|
||
|
|
date.setDate(trendStartDate.getDate() + index);
|
||
|
|
|
||
|
|
const dayKey = toDateKey(date);
|
||
|
|
return {
|
||
|
|
date: formatTrendLabel(date),
|
||
|
|
count: dailyUploads.get(dayKey) ?? 0,
|
||
|
|
};
|
||
|
|
});
|
||
|
|
|
||
|
|
const statusCountMap = new Map<ContractStatus, number>();
|
||
|
|
for (const item of contractsByStatus) {
|
||
|
|
statusCountMap.set(item.status, item._count._all);
|
||
|
|
}
|
||
|
|
|
||
|
|
const byStatus = (Object.keys(STATUS_LABELS) as ContractStatus[]).map(
|
||
|
|
(status) => ({
|
||
|
|
status: STATUS_LABELS[status],
|
||
|
|
count: statusCountMap.get(status) ?? 0,
|
||
|
|
}),
|
||
|
|
);
|
||
|
|
|
||
|
|
const byType = contractsByType
|
||
|
|
.filter((item) => item.type !== null)
|
||
|
|
.map((item) => ({
|
||
|
|
type: TYPE_LABELS[item.type as ContractType],
|
||
|
|
count: item._count._all,
|
||
|
|
}))
|
||
|
|
.sort((a, b) => b.count - a.count);
|
||
|
|
|
||
|
|
const completedSamples = completedContractsForTelemetry.length;
|
||
|
|
const avgSummaryLength =
|
||
|
|
completedSamples > 0
|
||
|
|
? Math.round(
|
||
|
|
completedContractsForTelemetry.reduce(
|
||
|
|
(sum, item) => sum + (item.summary?.length ?? 0),
|
||
|
|
0,
|
||
|
|
) / completedSamples,
|
||
|
|
)
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
const avgExtractedTextLength =
|
||
|
|
completedSamples > 0
|
||
|
|
? Math.round(
|
||
|
|
completedContractsForTelemetry.reduce(
|
||
|
|
(sum, item) => sum + (item.extractedText?.length ?? 0),
|
||
|
|
0,
|
||
|
|
) / completedSamples,
|
||
|
|
)
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
const avgKeyPointsPerContract =
|
||
|
|
completedSamples > 0
|
||
|
|
? Number(
|
||
|
|
(
|
||
|
|
completedContractsForTelemetry.reduce(
|
||
|
|
(sum, item) => sum + countKeyPoints(item.keyPoints),
|
||
|
|
0,
|
||
|
|
) / completedSamples
|
||
|
|
).toFixed(1),
|
||
|
|
)
|
||
|
|
: 0;
|
||
|
|
|
||
|
|
const summaryQuality = clamp((avgSummaryLength / 220) * 100, 0, 100);
|
||
|
|
const extractionDepth = clamp(
|
||
|
|
(avgExtractedTextLength / 4000) * 100,
|
||
|
|
0,
|
||
|
|
100,
|
||
|
|
);
|
||
|
|
const keyPointCoverage = clamp(avgKeyPointsPerContract * 12, 0, 100);
|
||
|
|
const sampleConsistency = clamp((completedSamples / 12) * 100, 0, 100);
|
||
|
|
|
||
|
|
const learningScore = Math.round(
|
||
|
|
summaryQuality * 0.35 +
|
||
|
|
extractionDepth * 0.35 +
|
||
|
|
keyPointCoverage * 0.2 +
|
||
|
|
sampleConsistency * 0.1,
|
||
|
|
);
|
||
|
|
|
||
|
|
const improvementHint =
|
||
|
|
completedLast7Days === 0
|
||
|
|
? "No new analyses in the last 7 days. Analyze more contracts to keep AI adaptation fresh."
|
||
|
|
: learningScore >= 80
|
||
|
|
? "Great quality trend. Continue diverse analyses to keep adaptation robust."
|
||
|
|
: learningScore >= 60
|
||
|
|
? "Stable quality. More varied document types can improve adaptation depth."
|
||
|
|
: "Quality profile is still maturing. Analyze more files to improve extraction consistency.";
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
stats: {
|
||
|
|
totalContracts,
|
||
|
|
analyzedContracts,
|
||
|
|
processingContracts,
|
||
|
|
uploadedContracts,
|
||
|
|
failedContracts,
|
||
|
|
analysisRate:
|
||
|
|
totalContracts > 0
|
||
|
|
? Math.round((analyzedContracts / totalContracts) * 100)
|
||
|
|
: 0,
|
||
|
|
},
|
||
|
|
chartData: {
|
||
|
|
byType,
|
||
|
|
byStatus,
|
||
|
|
trends,
|
||
|
|
},
|
||
|
|
premiumInfo: {
|
||
|
|
averagePremium: premiumStats._avg.premium
|
||
|
|
? Number(premiumStats._avg.premium)
|
||
|
|
: 0,
|
||
|
|
totalPremium: premiumStats._sum.premium
|
||
|
|
? Number(premiumStats._sum.premium)
|
||
|
|
: 0,
|
||
|
|
count: premiumStats._count._all,
|
||
|
|
},
|
||
|
|
aiLearningTelemetry: {
|
||
|
|
completedSamples,
|
||
|
|
completedLast7Days,
|
||
|
|
avgSummaryLength,
|
||
|
|
avgExtractedTextLength,
|
||
|
|
avgKeyPointsPerContract,
|
||
|
|
learningScore,
|
||
|
|
improvementHint,
|
||
|
|
},
|
||
|
|
recentContracts: recentAnalyzedContracts.map((contract) => ({
|
||
|
|
...contract,
|
||
|
|
premium: contract.premium ? Number(contract.premium) : null,
|
||
|
|
createdAt: contract.createdAt.toISOString(),
|
||
|
|
})),
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error("Failed to get user stats:", error);
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
error: "Failed to fetch statistics",
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|