PreRelease v2

This commit is contained in:
2026-03-28 23:46:45 +01:00
parent 6bf998a52a
commit 9993bd232f
39 changed files with 3964 additions and 1469 deletions

View File

@@ -1,530 +0,0 @@
/**
* Contract Server Actions
*
* Handles all contract-related operations including:
* - Saving uploaded contracts
* - Retrieving contracts
* - Analyzing contracts with AI
* - Deleting contracts
* - Asking questions about contracts
*
* Each action integrates with:
* - Clerk for authentication
* - Contract service for database operations
* - AI service for document analysis
* - Notification service for user feedback
*
* All operations include comprehensive error handling and notification creation.
*/
"use server";
import { auth } from "@clerk/nextjs/server";
import { revalidatePath } from "next/cache";
import {
ContractService,
saveContract as savePendingContract,
} from "@/lib/services/contract.service";
import { AIService } from "@/lib/services/ai.service";
import { NotificationService } from "@/lib/services/notification.service";
/**
* Saves a new contract after UploadThing upload
*
* Steps:
* 1. Get authenticated user from Clerk
* 2. Get internal user ID from database
* 3. Save contract to database with UPLOADED status
* 4. Create success notification for the user
* 5. Revalidate dashboard and contacts pages
*
* @param data - Contract file metadata from UploadThing
* @returns Success status with contract data or error message
*/
export async function saveContract(data: {
fileName: string;
fileUrl: string;
fileSize: number;
mimeType: string;
}) {
try {
// Get authenticated user
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
// Save contract
const result = await savePendingContract(data);
if (result.success && result.contract) {
// Get internal user ID for notification
const user = await ContractService.getUserByClerkId(clerkId);
if (user) {
// Create success notification
await NotificationService.create({
userId: user.id,
type: "SUCCESS",
title: "📄 Contract Uploaded",
message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`,
contractId: result.contract.id,
actionType: "UPLOAD_SUCCESS",
icon: "FileCheck",
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
});
}
revalidatePath("/contacts");
revalidatePath("/dashboard");
}
return result;
} catch (error: unknown) {
console.error("Save contract error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Retrieves all contracts for the authenticated user
*
* Steps:
* 1. Query database for contracts matching filter criteria
* 2. Serialize data: convert Decimal to number, dates to ISO strings
* 3. Return paginated/filtered contract list
*
* Supported Filters:
* - status: UPLOADED, PROCESSING, COMPLETED, FAILED
* - type: INSURANCE_AUTO, INSURANCE_HOME, etc.
* - search: Searches title, provider, policyNumber, fileName
* - userId: Auto-filtered to authenticated user
*
* @param filters - Filter criteria
* @returns Array of contracts with serialized data
*/
export async function getContracts(filters?: Record<string, unknown>) {
try {
const contracts = await ContractService.getAll(filters);
// Serialize contracts: convert Decimal to number, dates to ISO strings
const serializedContracts = contracts.map((contract: any) => ({
id: contract.id,
fileName: contract.fileName,
fileSize: contract.fileSize,
mimeType: contract.mimeType,
status: contract.status,
createdAt: contract.createdAt?.toISOString() || new Date().toISOString(),
fileUrl: contract.fileUrl,
// AI Analysis fields
title: contract.title || null,
type: contract.type || null,
provider: contract.provider || null,
policyNumber: contract.policyNumber || null,
startDate: contract.startDate ? contract.startDate.toISOString() : null,
endDate: contract.endDate ? contract.endDate.toISOString() : null,
premium: contract.premium
? parseFloat(contract.premium.toString())
: null,
summary: contract.summary || null,
keyPoints: contract.keyPoints || null,
}));
return { success: true, contracts: serializedContracts };
} catch (error: unknown) {
console.error("Get contracts error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Retrieves a single contract by ID
*
* @param id - Contract ID
* @returns Contract details or error
*/
export async function getContract(id: string) {
try {
const contract = await ContractService.getById(id);
return { success: true, contract };
} catch (error: unknown) {
console.error("Get contract error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Deletes a contract from both cloud storage and database
*
* Steps:
* 1. Get authenticated user from Clerk
* 2. Get internal user ID from database
* 3. Verify user owns the contract
* 4. Delete file from UploadThing cloud storage
* 5. Delete contract record from database
* 6. Create success notification
* 7. Revalidate pages
*
* @param id - Contract ID to delete
* @returns Success status or error message
*
* Security: Only the contract owner can delete their contracts
*/
export async function deleteContract(id: string) {
try {
// Get authenticated user
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
// Get contract to verify ownership and get title
const contract = await ContractService.getById(id);
const contractTitle = contract.title || contract.fileName;
// Get internal user ID
const user = await ContractService.getUserByClerkId(clerkId);
if (!user || contract.userId !== user.id) {
return {
success: false,
error: "Unauthorized: Contract not found or does not belong to you",
};
}
// Delete contract (handles both storage and database)
await ContractService.delete(id);
if (user) {
// Create success notification
await NotificationService.create({
userId: user.id,
type: "SUCCESS",
title: "🗑️ Contract Deleted",
message: `"${contractTitle}" has been permanently deleted.`,
actionType: "DELETE_SUCCESS",
icon: "Trash2",
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
});
}
revalidatePath("/contacts");
revalidatePath("/dashboard");
return { success: true, message: "Contract deleted successfully" };
} catch (error: unknown) {
console.error("Delete error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Retrieves dashboard statistics for the authenticated user
*
* Returns:
* - Total contracts count
* - Status breakdown (uploaded, processing, completed, failed)
* - Contract type distribution
* - AI learning telemetry data
*
* @returns Statistics object or error
*/
export async function getContractStats() {
try {
const stats = await ContractService.getStats();
return { success: true, stats };
} catch (error: unknown) {
console.error("Stats error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Analyzes a contract using AI service
*
* Steps:
* 1. Authenticate user
* 2. Get contract details
* 3. Update status to PROCESSING
* 4. Call AI service to analyze contract
* 5. Validate AI results
* 6. Save results to database with COMPLETED status
* 7. Create success notification
* 8. Return analysis results or error
*
* On Error:
* - Detects if contract is invalid vs analysis failed
* - Saves failure reason to database
* - Creates error notification
* - Returns appropriate error code for UI handling
*
* @param id - Contract ID to analyze
* @returns Success with analysis results or error with error code
*
* Error Codes:
* - INVALID_CONTRACT: File is not a valid contract document
* - ANALYSIS_ERROR: Analysis failed during processing
*/
export async function analyzeContractAction(id: string) {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return { success: false, error: "Unauthorized" };
}
// Get internal user ID
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return { success: false, error: "User not found" };
}
// Get contract
const contract = await ContractService.getById(id);
// Verify ownership
if (contract.userId !== user.id) {
return {
success: false,
error: "Unauthorized: Contract not found or does not belong to you",
};
}
// Update status to PROCESSING
await ContractService.updateStatus(id, "PROCESSING");
// Create processing notification
await NotificationService.create({
userId: user.id,
type: "INFO",
title: "⏳ Analyzing Contract",
message: `"${contract.fileName}" is being analyzed. This may take a few seconds...`,
contractId: id,
actionType: "ANALYSIS_STARTED",
icon: "Loader",
});
// Analyze with AI
const aiResults = await AIService.analyzeContract(contract.fileUrl, {
userId: contract.userId,
fileName: contract.fileName,
maxRetries: 3,
});
// Validate results
if (!AIService.validateAnalysis(aiResults)) {
console.error("❌ AI validation failed");
await ContractService.markFailed(
id,
"AI validation failed. The file may be incomplete or not a valid contract.",
);
// Create error notification
await NotificationService.create({
userId: user.id,
type: "ERROR",
title: "❌ Analysis Failed",
message:
"The AI could not validate the analysis result. The file may be incomplete or corrupted.",
contractId: id,
actionType: "ANALYSIS_FAILED",
icon: "AlertCircle",
});
return {
success: false,
error: "AI analysis validation failed. Please try again.",
errorCode: "ANALYSIS_ERROR",
};
}
// Save AI results to database (convert nulls to undefined for optional fields)
await ContractService.updateWithAIResults(id, {
...aiResults,
provider: aiResults.provider ?? undefined,
policyNumber: aiResults.policyNumber ?? undefined,
startDate: aiResults.startDate ?? undefined,
endDate: aiResults.endDate ?? undefined,
premium: aiResults.premium ?? undefined,
});
// Create success notification with extracted info
const contractTitle = aiResults.title || "Contract";
const contractProvider = aiResults.provider || "Unknown Provider";
const endDate = aiResults.endDate
? new Date(aiResults.endDate).toLocaleDateString()
: "N/A";
await NotificationService.create({
userId: user.id,
type: "SUCCESS",
title: "✅ Contract Analyzed",
message: `"${contractTitle}" from ${contractProvider} (Expires: ${endDate}) has been successfully analyzed and saved.`,
contractId: id,
actionType: "ANALYSIS_SUCCESS",
icon: "CheckCircle2",
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
});
revalidatePath("/contacts");
revalidatePath("/dashboard");
return {
success: true,
message: "Contract analyzed successfully!",
contract: aiResults,
};
} catch (error: unknown) {
console.error("Analyze error:", error);
try {
const { userId: clerkId } = await auth();
const user = clerkId && (await ContractService.getUserByClerkId(clerkId));
// Update contract status to FAILED
const reason =
error instanceof Error ? error.message : "Unknown error occurred";
await ContractService.markFailed(id, reason);
// Create error notification
if (user) {
const contract = await ContractService.getById(id);
await NotificationService.create({
userId: user.id,
type: "ERROR",
title: "❌ Analysis Failed",
message: reason,
contractId: id,
actionType: "ANALYSIS_ERROR",
icon: "AlertCircle",
});
}
} catch (e) {
console.error("Failed to update status or create notification", e);
}
const errorMessage =
error instanceof Error ? error.message : "Unknown error occurred";
// Detect if contract is invalid vs analysis failed
const invalidContractSignals = [
"not recognized as a valid contract",
"contract confidence is too low",
"does not contain enough contract-specific signals",
"uploaded file is not recognized as a contract",
"invalid_contract",
];
const normalizedError = errorMessage.toLowerCase();
const isInvalidContract = invalidContractSignals.some((signal) =>
normalizedError.includes(signal),
);
return {
success: false,
error: errorMessage,
errorCode: isInvalidContract ? "INVALID_CONTRACT" : "ANALYSIS_ERROR",
};
}
}
/**
* Asks a question about a specific contract using AI
*
* Steps:
* 1. Authenticate user
* 2. Validate question is not empty
* 3. Retrieve contract details
* 4. Call AI service with contract context
* 5. Return answer or error
*
* The AI uses the contract's extracted data to provide contextual answers about:
* - Contract terms and conditions
* - Dates and expiration information
* - Coverage details
* - Renewal terms
* - Specific clauses and provisions
*
* @param id - Contract ID
* @param question - User's question about the contract
* @returns AI-generated answer or error
*
* Example Questions:
* - "When does this insurance expire?"
* - "What is the coverage limit?"
* - "What are the exclusions?"
* - "How much is the premium?"
*/
export async function askContractQuestionAction(id: string, question: string) {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return { success: false, error: "Unauthorized" };
}
const trimmedQuestion = question.trim();
if (!trimmedQuestion) {
return { success: false, error: "Question cannot be empty" };
}
// Get contract details
const contract = await ContractService.getById(id);
// Get internal user ID
const user = await ContractService.getUserByClerkId(clerkId);
if (!user || contract.userId !== user.id) {
return {
success: false,
error: "Unauthorized: Contract not found or does not belong to you",
};
}
// Ask AI about contract with full context
const answer = await AIService.askAboutContract({
question: trimmedQuestion,
contract: {
fileName: contract.fileName,
title: contract.title,
type: contract.type,
provider: contract.provider,
policyNumber: contract.policyNumber,
startDate: contract.startDate,
endDate: contract.endDate,
premium: contract.premium
? parseFloat(contract.premium.toString())
: null,
summary: contract.summary,
keyPoints:
(contract.keyPoints as Record<string, unknown> | null) ?? null,
extractedText: contract.extractedText,
},
});
return { success: true, answer };
} catch (error: unknown) {
console.error("Ask contract question error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

View File

@@ -1,422 +0,0 @@
/**
* Notification Server Actions
*
* Handles all notification-related server actions including:
* - Fetching notifications
* - Marking notifications as read
* - Deleting notifications
* - Checking for deadline notifications
*
* These actions are called from client components and provide real-time
* notification management with Clerk authentication.
*/
"use server";
import { auth } from "@clerk/nextjs/server";
import { prisma } from "@/lib/db/prisma";
import { ContractService } from "@/lib/services/contract.service";
import { NotificationService } from "@/lib/services/notification.service";
const isNotificationTableMissingError = (error: unknown): boolean => {
if (!error || typeof error !== "object") return false;
const maybePrismaError = error as {
code?: string;
meta?: { table?: string };
message?: string;
};
if (maybePrismaError.code !== "P2021") return false;
const tableFromMeta = maybePrismaError.meta?.table ?? "";
const message = maybePrismaError.message ?? "";
return (
tableFromMeta.includes("Notification") ||
message.includes("public.Notification")
);
};
/**
* Fetches all unread notifications for the current authenticated user
*
* Uses Clerk authentication to get the current user's ID
*
* @param limit - Maximum number of notifications to return (default: 10)
* @returns Object with success status and notifications array
*
* Steps:
* 1. Authenticate user via Clerk
* 2. Get internal user ID from database using Clerk ID
* 3. Fetch unread notifications from database
* 4. Include contract details (title, endDate)
* 5. Return sorted by creation date (newest first)
*
* Example usage in component:
* ```typescript
* const { unreadNotifications } = await getNotifications();
* ```
*/
export async function getNotifications(limit: number = 10) {
try {
// Step 1: Authenticate user
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
// Step 2: Get internal user ID from database
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
// Step 3: Fetch unread notifications
const result = await NotificationService.getUnread(user.id, limit);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
data: [],
};
}
console.error("Get notifications error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Fetches complete notification history for the current user
*
* Returns both read and unread notifications for notification center/log view
*
* @param limit - Maximum number of notifications to return (default: 50)
* @returns Object with success status and all notifications array
*/
export async function getAllNotifications(limit: number = 50) {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
const result = await NotificationService.getAll(user.id, limit);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
data: [],
};
}
console.error("Get all notifications error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Gets count of unread notifications for badge display
*
* Used to show badge count on notification icon in the UI
*
* @returns Object with unread notification count
*
* Example usage:
* ```typescript
* const { data } = await getUnreadNotificationCount();
* // Display data.count as badge on notification bell icon
* ```
*/
export async function getUnreadNotificationCount() {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
const result = await NotificationService.getUnreadCount(user.id);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
data: { count: 0 },
};
}
console.error("Get unread count error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Marks a single notification as read
*
* @param notificationId - The ID of the notification to mark as read
* @returns Object with success status
*
* Security: Verifies the notification belongs to the current user
*/
export async function markNotificationAsRead(notificationId: string) {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
// Verify notification belongs to user
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
});
if (!notification || notification.userId !== user.id) {
return {
success: false,
error: "Unauthorized",
};
}
const result = await NotificationService.markAsRead(notificationId);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
};
}
console.error("Mark notification as read error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Marks all unread notifications as read for the current user
*
* @returns Object with success status and count of updated notifications
*/
export async function markAllNotificationsAsRead() {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
const result = await NotificationService.markAllAsRead(user.id);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
data: { count: 0 },
};
}
console.error("Mark all notifications as read error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Deletes a notification
*
* @param notificationId - The ID of the notification to delete
* @returns Object with success status
*
* Security: Verifies the notification belongs to the current user before deletion
*/
export async function deleteNotification(notificationId: string) {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
// Verify notification belongs to user
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
});
if (!notification || notification.userId !== user.id) {
return {
success: false,
error: "Unauthorized",
};
}
const result = await NotificationService.delete(notificationId);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
};
}
console.error("Delete notification error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
/**
* Checks for upcoming contract deadlines and creates notifications
*
* Scans all user's contracts and creates DEADLINE type notifications for:
* - 30 days before expiration (CRITICAL - 🔴)
* - 15 days before expiration (WARNING - 🟠)
* - 7 days before expiration (URGENT - 🟡)
*
* @returns Object with success status and count of created notifications
*
* Should be called:
* - On dashboard page load (to refresh deadline list)
* - Once per day at a scheduled time via cron job
* - When monitoring upcoming deadlines
*
* Example usage:
* ```typescript
* // In dashboard page effect
* useEffect(() => {
* checkDeadlineNotifications();
* }, []);
* ```
*/
export async function checkDeadlineNotifications() {
try {
const { userId: clerkId } = await auth();
if (!clerkId) {
return {
success: false,
error: "Unauthorized",
};
}
const user = await ContractService.getUserByClerkId(clerkId);
if (!user) {
return {
success: false,
error: "User not found",
};
}
const result = await NotificationService.checkUpcomingDeadlines(user.id);
return result;
} catch (error: unknown) {
if (isNotificationTableMissingError(error)) {
return {
success: true,
data: { count: 0, contractIds: [] },
};
}
console.error("Check deadline notifications error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

View File

@@ -1,14 +0,0 @@
"use server";
import { auth } from "@clerk/nextjs/server";
import { getUserStats } from "@/lib/services/stats.service";
export async function getStatsAction() {
const { userId } = await auth();
if (!userId) {
return { success: false, error: "Unauthorized" };
}
return await getUserStats(userId);
}

View File

@@ -1,49 +0,0 @@
// Server action to manually sync current user to database
"use server";
import { auth } from "@clerk/nextjs/server";
import { clerkClient } from "@clerk/nextjs/server";
import { prisma } from "@/lib/db/prisma";
export async function syncCurrentUser() {
try {
const { userId } = await auth();
if (!userId) {
return { success: false, error: "Not authenticated" };
}
// Get user details from Clerk
const clerk = await clerkClient();
const user = await clerk.users.getUser(userId);
// Create or update user in database
await prisma.user.upsert({
where: { clerkId: userId },
create: {
clerkId: userId,
email: user.emailAddresses[0]?.emailAddress || "",
firstName: user.firstName || null,
lastName: user.lastName || null,
imageUrl: user.imageUrl || null,
},
update: {
email: user.emailAddresses[0]?.emailAddress || "",
firstName: user.firstName || null,
lastName: user.lastName || null,
imageUrl: user.imageUrl || null,
},
});
return {
success: true,
message: `User ${user.emailAddresses[0]?.emailAddress} synced successfully!`,
};
} catch (error) {
console.error("Sync error:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Unknown error",
};
}
}

View File

@@ -1,9 +1,21 @@
// src/lib/services/ai.service.ts
import { GoogleGenerativeAI } from "@google/generative-ai";
import { prisma } from "@/lib/db/prisma";
import {
AnalyzeOptions,
ContractPrecheckResult,
NormalizedAnalysis,
} from "@/lib/services/ai/analysis.types";
import {
buildAnalysisPrompt,
buildPrevalidationPrompt,
} from "@/lib/services/ai/analysis.prompt";
import { parseJsonResponse as parseAiJsonResponse } from "@/lib/services/ai/analysis.parser";
import { normalizeAnalysis as normalizeAiAnalysis } from "@/lib/services/ai/analysis.normalizer";
// Read API key from environment once at module load.
const API_KEY = process.env.AI_API_KEY;
const API_KEY =
process.env.AI_API_KEY || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
if (!API_KEY) {
console.error("❌ AI_API_KEY is missing from environment variables");
@@ -14,45 +26,14 @@ if (!API_KEY) {
// Initialize Gemini
const genAI = new GoogleGenerativeAI(API_KEY);
// Runtime options used by analysis.
type AnalyzeOptions = {
userId?: string;
fileName?: string;
maxRetries?: number;
};
const PRIMARY_ANALYSIS_MODEL =
process.env.AI_MODEL_PRIMARY || "gemini-2.5-flash";
const FALLBACK_ANALYSIS_MODEL =
process.env.AI_MODEL_FALLBACK || "gemini-2.0-flash";
// Canonical shape returned by this service after normalization and validation.
type NormalizedAnalysis = {
title: string;
type:
| "INSURANCE_AUTO"
| "INSURANCE_HOME"
| "INSURANCE_HEALTH"
| "INSURANCE_LIFE"
| "LOAN"
| "CREDIT_CARD"
| "INVESTMENT"
| "OTHER";
provider: string | null;
policyNumber: string | null;
startDate: string | null;
endDate: string | null;
premium: number | null;
summary: string;
keyPoints: {
guarantees: string[];
exclusions: string[];
franchise: string | null;
importantDates: string[];
};
extractedText: string;
};
type ContractPrecheckResult = {
isValidContract: boolean;
confidence: number;
reason: string | null;
};
const ANALYSIS_MODELS = Array.from(
new Set([PRIMARY_ANALYSIS_MODEL, FALLBACK_ANALYSIS_MODEL]),
);
export class AIService {
/**
@@ -127,21 +108,9 @@ export class AIService {
);
}
// Step 3: Configure model for deterministic, JSON-centric extraction.
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: {
temperature: 0.1, // Low for consistency
topP: 0.95,
topK: 40,
maxOutputTokens: 8192,
responseMimeType: "application/json",
},
});
// Step 4: Build adaptive extraction context from previously analyzed contracts.
const adaptiveContext = await this.buildAdaptiveContext(options?.userId);
const basePrompt = this.buildPrompt({
const basePrompt = buildAnalysisPrompt({
adaptiveContext,
fileName: options?.fileName,
});
@@ -158,17 +127,12 @@ export class AIService {
: `\n\nCORRECTION MODE:\nYour previous response was invalid.\nReason: ${lastValidationError || "Invalid structure"}.\nReturn JSON only and keep every required field.\nPrevious invalid response:\n${previousRawResponse.slice(0, 2000)}`;
// Step 5: Ask model to extract strict JSON from the uploaded file.
const result = await model.generateContent([
`${basePrompt}${correctionHint}`,
{
inlineData: {
data: base64,
mimeType: mimeType,
},
},
]);
const text = await this.generateAnalysisWithFallback({
prompt: `${basePrompt}${correctionHint}`,
base64,
mimeType,
});
const text = result.response.text();
if (!text) {
lastValidationError = "No content in AI response";
continue;
@@ -178,7 +142,38 @@ export class AIService {
try {
// Step 6: Parse and normalize output into canonical structure.
const parsed = this.parseJsonResponse(text);
let parsed: unknown;
try {
parsed = this.parseJsonResponse(text);
} catch (parseError) {
console.warn(
"Initial JSON parse failed. Attempting repair with fallback model...",
);
const repaired = await this.repairMalformedJson(
text,
parseError instanceof Error
? parseError.message
: "Invalid JSON response",
);
if (!repaired) {
// Emergency fallback: try to extract key fields from raw text
console.warn(
"Repair model failed. Attempting emergency field extraction...",
);
const emergency = this.emergencyExtractFields(text);
if (emergency) {
console.log("✅ Emergency extraction succeeded");
parsed = this.parseJsonResponse(emergency);
} else {
throw parseError;
}
} else {
parsed = this.parseJsonResponse(repaired);
}
}
const normalized = this.normalizeAnalysis(parsed);
// Step 7: Reject non-contract uploads with explicit error.
@@ -225,7 +220,7 @@ export class AIService {
error.message?.includes("404")
) {
throw new Error(
"Invalid Gemini model. Ensure 'gemini-2.5-flash' is available in your Google Cloud project.",
`Invalid Gemini model configuration. Current models: ${ANALYSIS_MODELS.join(", ")}. Check model availability in your Gemini account.`,
);
} else if (
error.message?.includes("fetch") &&
@@ -234,7 +229,11 @@ export class AIService {
throw new Error(
"Download failed. Check if the file URL is correct and accessible.",
);
} else if (error.message?.includes("JSON")) {
} else if (
error.message?.includes("JSON") ||
error.message?.includes("No complete JSON object") ||
error.message?.includes("parse failed")
) {
console.error("❌ Raw response that failed to parse:", error);
console.error("Full error message:", error.message);
@@ -253,7 +252,7 @@ export class AIService {
);
} else {
throw new Error(
"Error parsing AI response. The response may not be valid JSON. Check console for details.",
"AI returned a malformed response format. Please retry analysis; if it fails again, the file may require OCR cleanup.",
);
}
} else if (error.message?.includes("quota")) {
@@ -267,88 +266,13 @@ export class AIService {
}
/**
* Build extraction prompt with strict schema + anti-hallucination instructions.
* Prompt generation has been moved to lib/services/ai/analysis.prompt.ts.
*/
private static buildPrompt(input?: {
adaptiveContext?: string;
fileName?: string;
}): string {
return `You are an expert in BFSI contract analysis (Banking, Financial Services, Insurance).
Document name: ${input?.fileName ?? "Unknown"}
${input?.adaptiveContext ?? ""}
Analyze this contract document and extract ALL important information in the EXACT JSON format below:
{
"title": "Descriptive contract title (e.g., Allianz Car Insurance)",
"type": "INSURANCE_AUTO",
"provider": "Name of the company or financial institution",
"policyNumber": "Policy number or contract number",
"startDate": "2024-01-01",
"endDate": "2024-12-31",
"premium": 1200.50,
"summary": "Clear and concise summary of the contract in a maximum of 34 sentences, covering the main guarantees and conditions",
"keyPoints": {
"guarantees": ["List of main guarantees or coverages provided"],
"exclusions": ["List of important exclusions to be aware of"],
"franchise": "Deductible amount or description (e.g., €500)",
"importantDates": ["Key dates and important deadlines"]
},
"contractValidation": {
"isValidContract": true,
"confidence": 88,
"reason": "Short reason if invalid, otherwise null"
},
"extractedText": "Full text extracted from the document with all details"
}
CRITICAL INSTRUCTIONS:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
TYPE — Must be EXACTLY one of the following values:
INSURANCE_AUTO (car insurance)
INSURANCE_HOME (home insurance)
INSURANCE_HEALTH (health insurance/mutual)
INSURANCE_LIFE (life insurance)
LOAN (bank loan)
CREDIT_CARD (credit card)
INVESTMENT (investment account)
OTHER (other type)
DATES — Strict format YYYY-MM-DD (e.g., 2024-01-15)
PREMIUM — Decimal number only (e.g., 1200.50, no text)
NULL — If information does not exist, use null (not an empty string "")
CONTRACT VALIDATION — Determine whether this document is truly a contract/policy/loan agreement.
- contractValidation.isValidContract must be false for invoices, receipts, ID cards, blank scans, random photos, marketing flyers, or unrelated files.
- confidence must be an integer from 0 to 100.
- reason must explain why invalid when isValidContract is false.
EXTRACTED TEXT — Must contain ALL visible text from the document
SUMMARY — Maximum 4 sentences, clear and informative
RESPONSE — Respond ONLY with valid JSON, no text before or after, no markdown
QUALITY GUARDRAILS:
- Never invent provider names, policy numbers, dates, or premium values.
- If uncertain, use null for that field.
- Keep extractedText raw and faithful to the visible document content.
- For summary and key points, prioritize practical legal and business implications.
NOW ANALYZE THE DOCUMENT:`;
return buildAnalysisPrompt(input);
}
/**
@@ -382,86 +306,232 @@ NOW ANALYZE THE DOCUMENT:`;
}
private static parseJsonResponse(text: string): unknown {
if (!text || typeof text !== "string" || text.trim().length === 0) {
throw new Error("AI response is empty or invalid.");
}
return parseAiJsonResponse(text);
}
// Remove potential markdown wrappers, comments, and extra whitespace
let cleanJson = text
.replace(/```json[\s\n]*/, "") // Remove opening markdown
.replace(/```[\s\n]*$/, "") // Remove closing markdown
.replace(/\/\/.*$/gm, "") // Remove JavaScript comments
.trim();
private static async generateAnalysisWithFallback(input: {
prompt: string;
base64: string;
mimeType: string;
}): Promise<string> {
let lastError: unknown = null;
// Check for common issues that indicate incomplete/corrupted response
const responsePreview = cleanJson.substring(0, 200);
console.log("🔍 AI Response preview:", responsePreview);
for (const modelName of ANALYSIS_MODELS) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
temperature: 0.1,
topP: 0.95,
topK: 40,
maxOutputTokens: 16384,
responseMimeType: "application/json",
},
});
// Try direct parse first
try {
const result = JSON.parse(cleanJson);
console.log("✅ JSON parsed successfully on first attempt");
return result;
} catch (firstError) {
console.warn(
"⚠️ First JSON parse failed:",
(firstError as Error).message,
);
}
const result = await model.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
// Fallback 1: Try removing non-JSON text (explanations before/after JSON)
try {
const firstCurly = cleanJson.indexOf("{");
const lastCurly = cleanJson.lastIndexOf("}");
if (firstCurly === -1 || lastCurly === -1 || firstCurly >= lastCurly) {
throw new Error(
"No JSON object wrapper found (missing { or }). Response may be incomplete.",
);
}
// Ensure we get complete closing braces for nested objects
let braceCount = 0;
let endIndex = firstCurly;
for (let i = firstCurly; i < cleanJson.length; i++) {
if (cleanJson[i] === "{") braceCount++;
if (cleanJson[i] === "}") braceCount--;
if (braceCount === 0) {
endIndex = i;
break;
const text = result.response.text();
if (text && text.trim().length > 0) {
console.log(`✅ Analysis with model ${modelName} succeeded`);
return text;
}
}
const jsonSlice = cleanJson.slice(firstCurly, endIndex + 1);
console.log("📝 Extracted JSON slice length:", jsonSlice.length);
const result = JSON.parse(jsonSlice);
console.log("✅ JSON parsed successfully after text removal");
return result;
} catch (fallbackError) {
console.error(
"❌ JSON fallback parsing failed:",
(fallbackError as Error).message,
);
console.error("Full raw response:", cleanJson.substring(0, 500));
// Last resort: Check for common formatting issues
if (cleanJson.includes('\\n"') || cleanJson.includes('\\"')) {
throw new Error(
"Response contains escaped quotes or newlines that couldn't be parsed. The contract may have corrupted text.",
} catch (error) {
lastError = error;
console.warn(
`Analysis with model ${modelName} failed. Trying next model.`,
error instanceof Error ? error.message : String(error),
);
}
}
if (!cleanJson.includes('"type"') && !cleanJson.includes('"title"')) {
throw new Error(
"Response is missing expected contract fields. It may not be a valid contract document.",
);
// All primary models failed. Try with more lenient generation settings as last resort
console.warn(
"All standard models failed. Trying with lenient generation config...",
);
try {
const fallbackModel = genAI.getGenerativeModel({
model: PRIMARY_ANALYSIS_MODEL,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 16384,
// Don't enforce JSON format; let model produce raw output
},
});
const result = await fallbackModel.generateContent([
input.prompt,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
const text = result.response.text();
if (text && text.trim().length > 0) {
console.log("✅ Lenient generation succeeded");
return text;
}
} catch (error) {
console.warn("Lenient generation also failed:", error);
}
throw lastError instanceof Error
? lastError
: new Error("All analysis models failed to generate content.");
}
private static async repairMalformedJson(
malformedResponse: string,
parseError: string,
): Promise<string | null> {
try {
const repairModelName = FALLBACK_ANALYSIS_MODEL;
const model = genAI.getGenerativeModel({
model: repairModelName,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 16384,
responseMimeType: "application/json",
},
});
const expectedSchema = {
language: "string|null",
title: "string",
type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER",
provider: "string|null",
policyNumber: "string|null",
startDate: "YYYY-MM-DD|null",
endDate: "YYYY-MM-DD|null",
premium: "number|null",
premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)",
summary: "string (min 10 chars)",
extractedText: "string (min 30 chars)",
keyPoints: {
guarantees: "string[]",
exclusions: "string[]",
franchise: "string|null",
importantDates: "string[]",
explainability:
"[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]",
},
keyPeople: "[{ name, role|null, email|null, phone|null }]",
contactInfo:
"{ name|null, email|null, phone|null, address|null, role|null }",
importantContacts:
"[{ name|null, email|null, phone|null, address|null, role|null }]",
relevantDates:
"[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]",
contractValidation: {
isValidContract: "boolean",
confidence: "number (0-100)",
reason: "string|null",
},
};
const repairPrompt = `You are a JSON repair engine for contract analysis.
Fix the malformed JSON response below and return ONLY valid, parseable JSON conforming to this schema:
${JSON.stringify(expectedSchema, null, 2)}
Rules:
1. Return ONLY the JSON object, no markdown, no explanations.
2. Preserve all values from the original response as accurately as possible.
3. Fix structural issues: missing braces, unescaped quotes, trailing commas, unmatched brackets.
4. For null/missing fields, use null value or empty array [] as appropriate.
5. Ensure all required text fields (title, summary, extractedText) have content.
6. All numeric values must be valid numbers.
7. All dates must be in YYYY-MM-DD format.
8. If type is unclear, use "OTHER".
9. Preserve explainability and evidence snippets when present.
Original parse error: ${parseError}
Malformed response to fix:
${malformedResponse.slice(0, 14000)}`;
const repaired = await model.generateContent(repairPrompt);
const repairedText = repaired.response.text()?.trim() || "";
if (repairedText.length === 0) {
return null;
}
throw new Error(
`Failed to parse AI response as JSON: ${(fallbackError as Error).message}`,
// Verify the repaired text is at least JSON-like before returning
if (!repairedText.includes("{")) {
return null;
}
return repairedText;
} catch (error) {
console.warn("JSON repair step failed:", error);
return null;
}
}
/**
* Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed.
* Builds a minimal but valid JSON structure from pattern-matched fields.
*/
private static emergencyExtractFields(rawText: string): string | null {
try {
const titleMatch = rawText.match(
/["']?title["']?\s*:\s*["']([^"']{5,200})/i,
);
const summaryMatch = rawText.match(
/summary["']?\s*:\s*["']([^"']{10,500})/i,
);
const extractedMatch = rawText.match(
/extractedText["']?\s*:\s*["']([^"']{30,})/i,
);
if (!titleMatch || !summaryMatch) {
return null;
}
const emergency = {
title: titleMatch[1]?.slice(0, 200) || "Contract",
type: "OTHER",
provider: null,
policyNumber: null,
startDate: null,
endDate: null,
premium: null,
premiumCurrency: null,
summary: summaryMatch[1]?.slice(0, 500) || "Contract analysis",
extractedText:
extractedMatch?.[1]?.slice(0, 12000) || rawText.slice(0, 12000),
keyPoints: {
guarantees: [],
exclusions: [],
franchise: null,
importantDates: [],
},
contractValidation: {
isValidContract: true,
confidence: 50,
reason: "Emergency partial extraction due to response malformation",
},
};
return JSON.stringify(emergency);
} catch {
return null;
}
}
@@ -476,44 +546,24 @@ NOW ANALYZE THE DOCUMENT:`;
mimeType: string;
fileName?: string;
}): Promise<ContractPrecheckResult> {
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 350,
responseMimeType: "application/json",
},
});
const rawText = await this.generatePrevalidationWithFallback(input);
const result = await model.generateContent([
`You are validating whether an uploaded document is a legal/financial contract.
let raw: any;
try {
raw = this.parseJsonResponse(rawText || "{}");
} catch (error) {
// If prevalidation JSON is malformed, assume it's a contract with moderate confidence
console.warn(
"Prevalidation JSON parse failed, assuming contract with moderate confidence",
);
return {
isValidContract: true,
confidence: 60,
reason:
"Prevalidation response was malformed, but document appears contract-like",
};
}
File name: ${input.fileName ?? "Unknown"}
Return ONLY JSON:
{
"isValidContract": true,
"confidence": 0,
"reason": null
}
Rules:
- isValidContract=false for invoices, receipts, identity cards, random photos/screenshots, blank pages, flyers, or unrelated files.
- confidence is an integer from 0 to 100.
- reason must be concise and user-friendly when invalid.
- If valid, reason can be null.
`,
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
const raw = this.parseJsonResponse(result.response.text() || "{}");
const maybe = raw as Partial<ContractPrecheckResult>;
const isValidContract = Boolean(maybe.isValidContract);
@@ -532,95 +582,55 @@ Rules:
};
}
private static async generatePrevalidationWithFallback(input: {
base64: string;
mimeType: string;
fileName?: string;
}): Promise<string> {
let lastError: unknown = null;
for (const modelName of ANALYSIS_MODELS) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 350,
responseMimeType: "application/json",
},
});
const result = await model.generateContent([
buildPrevalidationPrompt(input.fileName),
{
inlineData: {
data: input.base64,
mimeType: input.mimeType,
},
},
]);
const text = result.response.text();
if (text && text.trim().length > 0) {
return text;
}
} catch (error) {
lastError = error;
console.warn(
`Pre-validation with model ${modelName} failed. Trying next model.`,
);
}
}
throw lastError instanceof Error
? lastError
: new Error("All pre-validation models failed to generate content.");
}
private static normalizeAnalysis(input: any): NormalizedAnalysis {
// Ensure contract type belongs to supported enum.
const validTypes = new Set([
"INSURANCE_AUTO",
"INSURANCE_HOME",
"INSURANCE_HEALTH",
"INSURANCE_LIFE",
"LOAN",
"CREDIT_CARD",
"INVESTMENT",
"OTHER",
]);
const type =
typeof input?.type === "string" && validTypes.has(input.type)
? input.type
: null;
if (!type) {
throw new Error("Contract type is missing or invalid.");
}
const title = String(input?.title || "").trim();
const summary = String(input?.summary || "").trim();
const extractedText = String(input?.extractedText || "").trim();
if (title.length < 3) {
throw new Error("Title is missing or too short.");
}
if (summary.length < 10) {
throw new Error("Summary is missing or too short.");
}
if (extractedText.length < 50) {
throw new Error("Extracted text is missing or too short.");
}
// Helper: normalize unknown primitive into string|null.
const toStringOrNull = (value: unknown): string | null => {
const normalized = String(value ?? "").trim();
return normalized.length > 0 ? normalized : null;
};
// Helper: accept only strict ISO date values.
const toDateOrNull = (value: unknown): string | null => {
const candidate = String(value ?? "").trim();
if (!candidate) return null;
const isIsoDate = /^\d{4}-\d{2}-\d{2}$/.test(candidate);
return isIsoDate ? candidate : null;
};
// Helper: sanitize array values into non-empty text list.
const toStringList = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0);
};
// Premium must be numeric and non-negative.
const premiumValue =
input?.premium === null || input?.premium === undefined
? null
: Number(input.premium);
const premium =
premiumValue !== null &&
Number.isFinite(premiumValue) &&
premiumValue >= 0
? Number(premiumValue.toFixed(2))
: null;
return {
title,
type,
provider: toStringOrNull(input?.provider),
policyNumber: toStringOrNull(input?.policyNumber),
startDate: toDateOrNull(input?.startDate),
endDate: toDateOrNull(input?.endDate),
premium,
summary,
keyPoints: {
guarantees: toStringList(input?.keyPoints?.guarantees),
exclusions: toStringList(input?.keyPoints?.exclusions),
franchise: toStringOrNull(input?.keyPoints?.franchise),
importantDates: toStringList(input?.keyPoints?.importantDates),
},
extractedText,
};
return normalizeAiAnalysis(input);
}
private static async buildAdaptiveContext(userId?: string): Promise<string> {
@@ -643,6 +653,7 @@ Rules:
provider: true,
policyNumber: true,
summary: true,
keyPoints: true,
},
});
@@ -680,6 +691,49 @@ Rules:
.slice(0, 4)
.map((value) => value.replace(/[A-Za-z0-9]/g, "X"));
const allExplainability = examples
.flatMap((item) => {
const maybeExplainability = (item.keyPoints as any)?.explainability;
return Array.isArray(maybeExplainability) ? maybeExplainability : [];
})
.slice(0, 120);
const explainabilityByField = count(
allExplainability
.map((entry: any) => String(entry?.field ?? "").trim())
.filter((value: string) => value.length > 0),
);
const confidenceValues = allExplainability
.map((entry: any) => Number(entry?.sourceHints?.confidence))
.filter((value: number) => Number.isFinite(value));
const avgEvidenceConfidence = confidenceValues.length
? Math.round(
confidenceValues.reduce(
(sum: number, value: number) => sum + value,
0,
) / confidenceValues.length,
)
: null;
const learnedLanguages = count(
examples
.map((item) => (item.keyPoints as any)?.aiMeta?.language)
.map((value) => String(value ?? "").trim())
.filter((value: string) => value.length > 0),
);
const learnedKeyRoles = count(
examples
.flatMap((item) => {
const people = (item.keyPoints as any)?.aiMeta?.keyPeople;
return Array.isArray(people) ? people : [];
})
.map((person: any) => String(person?.role ?? "").trim())
.filter((value: string) => value.length > 0),
);
const avgSummaryLength =
examples
.map((item) => item.summary?.length ?? 0)
@@ -690,6 +744,10 @@ Rules:
- Frequent provider naming patterns: ${topProviders.join(", ") || "N/A"}
- Example policy number shape patterns: ${policyPatterns.join(", ") || "N/A"}
- Typical summary length target: around ${Math.round(avgSummaryLength)} characters.
- Dominant learned languages: ${learnedLanguages.join(", ") || "N/A"}
- Most evidenced fields: ${explainabilityByField.join(", ") || "N/A"}
- Average evidence confidence: ${avgEvidenceConfidence ?? "N/A"}
- Frequent key roles identified: ${learnedKeyRoles.join(", ") || "N/A"}
Use this context only as formatting guidance. Do not force it if current document content differs.`;
}
@@ -711,7 +769,7 @@ Use this context only as formatting guidance. Do not force it if current documen
const modelReason = String(raw?.contractValidation?.reason ?? "").trim();
const legalSignalRegex =
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability/i;
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability|lease|service|supplier|client|vendor|annex|appendix|signature|party|contrat|assurance|banque|credit|emprunteur|garantie|echeance|duree|clause/i;
const hasLegalSignals = legalSignalRegex.test(normalized.extractedText);
const hasStructuredSignal =
Boolean(normalized.provider) ||
@@ -732,6 +790,16 @@ Use this context only as formatting guidance. Do not force it if current documen
);
}
// For generic contracts mapped to OTHER, keep a lighter heuristic so valid non-BFSI contracts pass.
if (normalized.type === "OTHER") {
if (!hasLegalSignals && normalized.extractedText.length < 120) {
throw new Error(
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals.",
);
}
return;
}
if (!hasLegalSignals && !hasStructuredSignal) {
throw new Error(
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals.",
@@ -794,20 +862,10 @@ Use this context only as formatting guidance. Do not force it if current documen
summary?: string | null;
keyPoints?: Record<string, unknown> | null;
extractedText?: string | null;
language?: string | null; // NEW: contract's detected language
};
}) {
try {
// Configure fast Q&A model tuned for concise answers.
const model = genAI.getGenerativeModel({
model: "gemini-2.5-flash",
generationConfig: {
temperature: 0.2,
topP: 0.95,
topK: 40,
maxOutputTokens: 2048,
},
});
// Keep context bounded to avoid overlong prompts and token waste.
const extractedTextSnippet = (input.contract.extractedText || "")
.slice(0, 12000)
@@ -816,10 +874,28 @@ Use this context only as formatting guidance. Do not force it if current documen
input.contract.type,
);
const prompt = `You are a senior BFSI contract advisor.
// Detect contract language for multilingual response
const contractLanguage = input.contract.language || "en";
const languageName =
{
en: "English",
fr: "French",
de: "German",
es: "Spanish",
it: "Italian",
pt: "Portuguese",
nl: "Dutch",
pl: "Polish",
ja: "Japanese",
zh: "Chinese",
ar: "Arabic",
}[contractLanguage] || "English";
const prompt = `You are a senior BFSI contract advisor. IMPORTANT: Respond entirely in ${languageName} to match the contract language.
Contract metadata:
- File: ${input.contract.fileName}
- Language: ${languageName}
- Title: ${input.contract.title ?? "N/A"}
- Type: ${input.contract.type ?? "N/A"}
- Provider: ${input.contract.provider ?? "N/A"}
@@ -837,12 +913,13 @@ ${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)}
Extracted Text:
${extractedTextSnippet || "N/A"}
User question:
User question (${languageName}):
${input.question}
Instructions:
- RESPOND ENTIRELY IN ${languageName}. This is critical.
- Write in clear, professional, business-oriented plain text.
- Do NOT use markdown or special formatting symbols, including: **, __, #, *, -, backticks.
- Do NOT use markdown or special formatting symbols, including: **, __, #, *, -, backticks with one exception: you can use | for separators if needed for clarity
- Do NOT quote large raw excerpts from extracted text unless strictly necessary.
- Synthesize and explain the implications in practical terms instead of copying file content.
- Base your answer ONLY on the provided contract content.
@@ -852,21 +929,54 @@ Instructions:
- For legal context, use wording like: "Under general EU/US legal principles..." and avoid citing specific article numbers unless explicitly present in the contract content.
- Never claim certainty where the contract text is ambiguous.
- Keep the answer concise, executive, and decision-oriented.
- Use the same language preference throughout (${languageName}).
Response structure:
Response structure (in ${languageName}):
1) Direct answer in one sentence.
2) Business impact in one to two sentences (risk, cost, operational effect).
3) General legal context in one to two sentences when relevant.
4) Recommended next step in one sentence.
Compliance note:
Compliance note (in ${languageName}):
Include one short disclaimer only when legal context is discussed: "This is general information, not formal legal advice."`;
// Execute completion and sanitize styling artifacts from response.
const result = await model.generateContent(prompt);
const rawAnswer = result.response.text()?.trim();
// Execute completion with model fallback and sanitize styling artifacts.
let rawAnswer = "";
let lastError: unknown = null;
for (const modelName of ANALYSIS_MODELS) {
try {
const model = genAI.getGenerativeModel({
model: modelName,
generationConfig: {
temperature: 0.2,
topP: 0.95,
topK: 40,
maxOutputTokens: 2048,
},
});
const result = await model.generateContent(prompt);
rawAnswer = result.response.text()?.trim() || "";
if (rawAnswer) {
console.log(
`✅ Q&A with model ${modelName} succeeded in ${languageName}`,
);
break;
}
} catch (error) {
lastError = error;
console.warn(
`Q&A with model ${modelName} failed. Trying next model.`,
);
}
}
if (!rawAnswer) {
if (lastError instanceof Error) {
throw lastError;
}
throw new Error("No response generated");
}

View File

@@ -0,0 +1,222 @@
import {
NormalizedAnalysis,
SUPPORTED_CONTRACT_TYPES,
SupportedContractType,
ContactInfo,
KeyPerson,
ExplainabilityItem,
} from "./analysis.types";
function mapContractType(rawType: unknown): SupportedContractType {
const value = String(rawType ?? "")
.trim()
.toUpperCase()
.replace(/\s+/g, "_");
if (SUPPORTED_CONTRACT_TYPES.includes(value as SupportedContractType)) {
return value as SupportedContractType;
}
const aliases: Record<string, SupportedContractType> = {
AUTO_INSURANCE: "INSURANCE_AUTO",
HOME_INSURANCE: "INSURANCE_HOME",
HEALTH_INSURANCE: "INSURANCE_HEALTH",
LIFE_INSURANCE: "INSURANCE_LIFE",
MORTGAGE: "LOAN",
CREDIT: "LOAN",
CARD_CREDIT: "CREDIT_CARD",
};
return aliases[value] ?? "OTHER";
}
function toStringOrNull(value: unknown): string | null {
const normalized = String(value ?? "").trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeCurrency(value: unknown): string | null {
const raw = String(value ?? "")
.trim()
.toUpperCase();
if (!raw) return null;
const symbolMap: Record<string, string> = {
"€": "EUR",
$: "USD",
"£": "GBP",
};
if (symbolMap[raw]) {
return symbolMap[raw];
}
// Accept ISO-like 3-letter currencies and common BFSI currencies.
if (/^[A-Z]{3}$/.test(raw)) {
return raw;
}
return null;
}
function toDateOrNull(value: unknown): string | null {
const candidate = String(value ?? "").trim();
if (!candidate) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
return candidate;
}
const parsed = new Date(candidate);
if (Number.isNaN(parsed.getTime())) return null;
return parsed.toISOString().slice(0, 10);
}
function toStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((item) => String(item ?? "").trim())
.filter((item) => item.length > 0)
.slice(0, 25);
}
function parseContactInfo(input: any): ContactInfo {
return {
name: toStringOrNull(input?.name),
email: toStringOrNull(input?.email),
phone: toStringOrNull(input?.phone),
address: toStringOrNull(input?.address),
role: toStringOrNull(input?.role),
};
}
function parseKeyPeople(input: any): KeyPerson[] {
if (!Array.isArray(input)) return [];
return input.slice(0, 10).map((person) => ({
name: String(person?.name ?? "").trim() || "Unknown",
role: toStringOrNull(person?.role),
email: toStringOrNull(person?.email),
phone: toStringOrNull(person?.phone),
}));
}
function parseRelevantDates(input: any): Array<{
date: string;
description: string;
type: "EXPIRATION" | "RENEWAL" | "PAYMENT" | "REVIEW" | "OTHER";
}> {
if (!Array.isArray(input)) return [];
return input.slice(0, 15).map((dateObj) => {
const dateStr = toDateOrNull(dateObj?.date);
const type = String(dateObj?.type ?? "OTHER").toUpperCase();
const isValidType = [
"EXPIRATION",
"RENEWAL",
"PAYMENT",
"REVIEW",
"OTHER",
].includes(type);
return {
date: dateStr || "0000-01-01",
description:
String(dateObj?.description ?? "")
.trim()
.slice(0, 200) || "Important date",
type: (isValidType ? type : "OTHER") as
| "EXPIRATION"
| "RENEWAL"
| "PAYMENT"
| "REVIEW"
| "OTHER",
};
});
}
function parseExplainability(input: any): ExplainabilityItem[] {
if (!Array.isArray(input)) return [];
return input
.slice(0, 30)
.map((item) => {
const field = String(item?.field ?? "").trim();
const why = String(item?.why ?? "").trim();
const sourceSnippet = String(item?.sourceSnippet ?? "").trim();
if (!field || !why || !sourceSnippet) return null;
const confidenceRaw = Number(item?.sourceHints?.confidence);
const confidence = Number.isFinite(confidenceRaw)
? Math.max(0, Math.min(100, Math.round(confidenceRaw)))
: null;
return {
field: field.slice(0, 80),
why: why.slice(0, 260),
sourceSnippet: sourceSnippet.slice(0, 480),
sourceHints: {
page: toStringOrNull(item?.sourceHints?.page),
section: toStringOrNull(item?.sourceHints?.section),
confidence,
},
} as ExplainabilityItem;
})
.filter((value): value is ExplainabilityItem => value !== null);
}
export function normalizeAnalysis(input: any): NormalizedAnalysis {
const title = String(input?.title || "").trim() || "Untitled Contract";
const summary = String(input?.summary || "").trim();
const extractedText = String(input?.extractedText || "").trim();
if (summary.length < 10) {
throw new Error("Summary is missing or too short.");
}
if (extractedText.length < 30) {
throw new Error("Extracted text is missing or too short.");
}
const premiumValue =
input?.premium === null || input?.premium === undefined
? null
: Number(input.premium);
const premium =
premiumValue !== null && Number.isFinite(premiumValue) && premiumValue >= 0
? Number(premiumValue.toFixed(2))
: null;
const language = toStringOrNull(input?.language) || "en";
return {
title,
type: mapContractType(input?.type),
provider: toStringOrNull(input?.provider),
policyNumber: toStringOrNull(input?.policyNumber),
startDate: toDateOrNull(input?.startDate),
endDate: toDateOrNull(input?.endDate),
premium,
premiumCurrency: normalizeCurrency(input?.premiumCurrency),
summary,
keyPoints: {
guarantees: toStringList(input?.keyPoints?.guarantees),
exclusions: toStringList(input?.keyPoints?.exclusions),
franchise: toStringOrNull(input?.keyPoints?.franchise),
importantDates: toStringList(input?.keyPoints?.importantDates),
explainability: parseExplainability(input?.keyPoints?.explainability),
},
extractedText: extractedText.slice(0, 12000),
language,
keyPeople: parseKeyPeople(input?.keyPeople),
contactInfo: parseContactInfo(input?.contactInfo),
importantContacts: Array.isArray(input?.importantContacts)
? input.importantContacts
.slice(0, 10)
.map((c: any) => parseContactInfo(c))
: [],
relevantDates: parseRelevantDates(input?.relevantDates),
};
}

View File

@@ -0,0 +1,110 @@
function stripMarkdownFences(value: string): string {
return value
.replace(/^```json\s*/i, "")
.replace(/^```\s*/i, "")
.replace(/\s*```$/, "")
.trim();
}
function extractBalancedJson(text: string): string | null {
let start = -1;
let inString = false;
let escaped = false;
const stack: string[] = [];
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (start === -1) {
if (char === "{" || char === "[") {
start = i;
stack.push(char);
}
continue;
}
if (inString) {
if (!escaped && char === "\\") {
escaped = true;
continue;
}
if (!escaped && char === '"') {
inString = false;
}
escaped = false;
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{" || char === "[") {
stack.push(char);
continue;
}
if (char === "}" || char === "]") {
const last = stack[stack.length - 1];
const isMatch =
(last === "{" && char === "}") || (last === "[" && char === "]");
if (!isMatch) {
return null;
}
stack.pop();
if (stack.length === 0 && start !== -1) {
return text.slice(start, i + 1);
}
}
}
return null;
}
function sanitizeLooseJson(value: string): string {
return value
.replace(/[\u201C\u201D]/g, '"')
.replace(/[\u2018\u2019]/g, "'")
.replace(/,\s*([}\]])/g, "$1")
.trim();
}
export function parseJsonResponse(text: string): unknown {
if (!text || typeof text !== "string" || text.trim().length === 0) {
throw new Error("AI response is empty.");
}
const cleaned = stripMarkdownFences(text);
try {
return JSON.parse(cleaned);
} catch {
// continue to robust fallback
}
const extracted = extractBalancedJson(cleaned);
if (!extracted) {
throw new Error(
`No complete JSON object found in AI response. Preview: ${cleaned.slice(0, 220)}`,
);
}
try {
return JSON.parse(extracted);
} catch {
const sanitized = sanitizeLooseJson(extracted);
try {
return JSON.parse(sanitized);
} catch (error) {
const message =
error instanceof Error ? error.message : "unknown parse error";
throw new Error(
`JSON parse failed after recovery attempts: ${message}. Preview: ${sanitized.slice(0, 220)}`,
);
}
}
}

View File

@@ -0,0 +1,165 @@
export function buildAnalysisPrompt(input?: {
adaptiveContext?: string;
fileName?: string;
}): string {
return `You are an expert in contract analysis for BFSI and general legal/business contracts.
You support multi-language analysis and will automatically detect the contract language.
Document name: ${input?.fileName ?? "Unknown"}
${input?.adaptiveContext ?? ""}
Analyze this contract document completely and return JSON in the EXACT structure below.
CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markdown, backticks, or explanations.
{
"language": "en",
"title": "Descriptive contract title",
"type": "INSURANCE_AUTO",
"provider": "Company or institution name",
"policyNumber": "Policy/contract/reference number",
"startDate": "2024-01-01",
"endDate": "2025-12-31",
"premium": 1200.50,
"premiumCurrency": "TND",
"summary": "Professional, comprehensive 4-6 sentence summary in the contract's language. Include: main parties, key obligations, coverage/benefits, exclusions, important deadlines, key contacts. Use **bold** for: names, numbers, dates, amounts, important terms.",
"keyPoints": {
"guarantees": ["**Main Benefit 1**: Description", "**Main Benefit 2**: Description"],
"exclusions": ["**Exclusion 1**: Description with impact", "**Exclusion 2**: Description"],
"franchise": "**Deductible/Penalty**: €150 per claim or equivalent",
"importantDates": ["**Renewal Date**: 31 December annually", "**Payment Deadline**: 15th of each month"],
"explainability": [
{
"field": "endDate",
"why": "Extracted as contract expiration because the clause explicitly sets validity end.",
"sourceSnippet": "Durée du prêt: échéance finale fixée au 10 avril 2044.",
"sourceHints": { "page": "1", "section": "Durée/Échéancier", "confidence": 92 }
},
{
"field": "premium",
"why": "Detected monetary obligation from insurance/fee clause.",
"sourceSnippet": "Coût total estimé de 18 240,00 TND.",
"sourceHints": { "page": "2", "section": "Coût / Prime", "confidence": 88 }
}
]
},
"keyPeople": [
{"name": "**John Smith**", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"},
{"name": "**Jane Doe**", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
],
"contactInfo": {
"name": "**Policy Holder Name**",
"email": "holder@email.com",
"phone": "+33612345678",
"address": "123 Main Street, City, Postal Code",
"role": "Insured Person"
},
"importantContacts": [
{"name": "**Claims Department**", "email": "claims@insurer.com", "phone": "+33800000000"},
{"name": "**Customer Service**", "email": "support@insurer.com", "phone": "+33800111111"}
],
"relevantDates": [
{"date": "2025-12-31", "description": "**Policy Expiration Date**", "type": "EXPIRATION"},
{"date": "2025-10-31", "description": "**Renewal Notice Deadline** (60 days before expiration)", "type": "RENEWAL"},
{"date": "1970-01-15", "description": "**Monthly Payment Due Date**", "type": "PAYMENT"}
],
"extractedText": "Most relevant extracted text, preserving original structure and keywords. Include key clauses, definitions, obligations. Max 12000 chars.",
"contractValidation": {
"isValidContract": true,
"confidence": 88,
"reason": null
}
}
TYPE must be one of:
INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER
CRITICAL FIELD EXTRACTION RULES:
1. **Language Detection**: Detect and return the contract's primary language (en, fr, de, es, it, pt, etc.). If mixed, return dominant language.
2. **Summary (VERY IMPORTANT)**:
- Write 4-6 comprehensive sentences covering: parties involved, contract scope, key obligations, main coverage/benefits, critical exclusions, important deadlines
- Use **Party Name** for persons/entities mentioned
- Use **number** for all quantities, dates, amounts, percentages
- Use **YYYY-MM-DD** format for dates with **bold**
- Language: Professional business French, English, or contract's native language
- MUST be detailed enough that reader understands contract without opening PDF
3. **Key People Extraction**:
- Extract all named individuals: policy holders, insured parties, beneficiaries, signatories, agents, brokers
- Include roles, contact methods when visible in contract
- Use **bold** for names: {"name": "**John Smith**", ...}
4. **Contact Information**:
- contactInfo: Details of PRIMARY policy holder or contract party
- importantContacts: Agent, broker, support teams, claims department with **bold** for names
5. **Relevant Dates**:
- Extract ALL dates with business meaning: expiration, renewal, payment due dates, review dates
- For recurring dates (monthly, annually): show pattern like "1970-01-15" for "15th of each month"
- Include type: EXPIRATION, RENEWAL, PAYMENT, REVIEW, or OTHER
- Each date must have clear **bold** description explaining its significance
6. **Key Points**:
- Use **bold** for: benefit names, exclusion types, monetary amounts, coverage limits
- Example: "**Motor Coverage**: Collision and theft protection up to **€50,000**"
- Make exclusions explicit and impactful
- Include franchise/deductible with bold currency and amount
7. **Guarantees & Exclusions**:
- Be specific: "**Theft Coverage** includes keys, GPS, and aftermarket electronics"
- For exclusions, explain impact: "**Mechanical wear excluded** - means breakdowns in years 3+ not covered"
8. **Email/Phone Extraction**: If present in contract, extract:
- Email addresses in format: contact@domain.com
- Phone numbers with country code: +33 for France, +44 for UK, etc.
9. **Explainability (MANDATORY)**:
- In keyPoints.explainability, include at least 6 items for critical fields when available:
title, provider, policyNumber, startDate, endDate, premium, key obligations, key exclusions.
- Each item MUST contain:
- field: exact extracted field name
- why: one sentence explaining extraction logic
- sourceSnippet: short verbatim quote from document supporting the field
- sourceHints.page: page number if inferable, otherwise null
- sourceHints.section: section title/heading when inferable, otherwise null
- sourceHints.confidence: 0..100 confidence for that field extraction
- Keep sourceSnippet short (max 280 chars) but sufficiently specific to audit.
- Never invent snippet text not present in document.
Field Type Rules:
- dates: ISO format YYYY-MM-DD or null. For recurring patterns, use canonical date (e.g., "0000-01-15" for "15th each month")
- premium: Positive number or null. NO currency symbols and NO currency conversion.
- premiumCurrency: Use the exact currency mentioned in contract (e.g., TND, EUR, USD, MAD, DZD, GBP, CHF). Never convert currency.
- keyPeople, contactInfo arrays: Can include null values for missing fields
- type: MUST be one of the 8 contract types. Default to OTHER if unsure.
- confidence: 1-100, higher for clear data, lower for ambiguous/partial info
Validation:
- isValidContract: true for all actual contracts (even type=OTHER), false only for non-contract files
- reason: null if valid, brief explanation if invalid
MUST return VALID JSON parseable with JSON.parse() in ONE line of pure JSON.`;
}
export function buildPrevalidationPrompt(fileName?: string): string {
return `You are validating whether an uploaded document is a legal/financial contract in any language.
File name: ${fileName ?? "Unknown"}
Return ONLY JSON (no markdown, no backticks, no explanations):
{
"isValidContract": true,
"confidence": 0,
"reason": null
}
Rules:
- isValidContract=false for invoices, receipts, identity cards, random photos/screenshots, blank pages, flyers, or unrelated files
- confidence is an integer from 0 to 100
- reason must be concise and user-friendly when invalid
- If valid, reason can be null
- This must be valid JSON parseable with JSON.parse()
- Return ONLY the JSON object, nothing else`;
}

View File

@@ -0,0 +1,80 @@
export const SUPPORTED_CONTRACT_TYPES = [
"INSURANCE_AUTO",
"INSURANCE_HOME",
"INSURANCE_HEALTH",
"INSURANCE_LIFE",
"LOAN",
"CREDIT_CARD",
"INVESTMENT",
"OTHER",
] as const;
export type SupportedContractType = (typeof SUPPORTED_CONTRACT_TYPES)[number];
export type AnalyzeOptions = {
userId?: string;
fileName?: string;
maxRetries?: number;
};
export type ContactInfo = {
name?: string | null;
email?: string | null;
phone?: string | null;
address?: string | null;
role?: string | null;
};
export type KeyPerson = {
name: string;
role?: string | null;
email?: string | null;
phone?: string | null;
};
export type ExplainabilityItem = {
field: string;
why: string;
sourceSnippet: string;
sourceHints?: {
page?: string | null;
section?: string | null;
confidence?: number | null;
};
};
export type NormalizedAnalysis = {
title: string;
type: SupportedContractType;
provider: string | null;
policyNumber: string | null;
startDate: string | null;
endDate: string | null;
premium: number | null;
premiumCurrency?: string | null;
summary: string;
keyPoints: {
guarantees: string[];
exclusions: string[];
franchise: string | null;
importantDates: string[];
explainability?: ExplainabilityItem[];
};
extractedText: string;
// New enhanced fields
language?: string | null;
keyPeople: KeyPerson[];
contactInfo: ContactInfo;
importantContacts: ContactInfo[];
relevantDates: Array<{
date: string;
description: string;
type: "EXPIRATION" | "RENEWAL" | "PAYMENT" | "REVIEW" | "OTHER";
}>;
};
export type ContractPrecheckResult = {
isValidContract: boolean;
confidence: number;
reason: string | null;
};

View File

@@ -10,6 +10,7 @@ import type {
ContractType,
} from "@/types/contract.types";
import { revalidatePath } from "next/cache";
import { NotificationService } from "@/lib/services/notification.service";
const utapi = new UTApi();
@@ -124,7 +125,7 @@ export class ContractService {
return null;
};
return await prisma.contract.update({
const contract = await prisma.contract.update({
where: { id },
data: {
title: aiResults.title,
@@ -140,6 +141,23 @@ export class ContractService {
status: "COMPLETED",
},
});
// Check for upcoming deadlines after contract is completed
try {
const user = await prisma.user.findUnique({
where: { id: contract.userId },
select: { id: true },
});
if (user) {
await NotificationService.checkUpcomingDeadlines(user.id);
}
} catch (error) {
console.warn("Failed to check upcoming deadlines:", error);
// Don't fail the contract update if deadline check fails
}
return contract;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

View File

@@ -70,6 +70,46 @@ interface NotificationResponse {
}
export class NotificationService {
static async cleanupReadNonDeadline(
userId: string,
): Promise<NotificationResponse> {
try {
const result = await prisma.notification.deleteMany({
where: {
userId,
read: true,
NOT: {
type: "DEADLINE",
},
},
});
return {
success: true,
message: `Cleaned ${result.count} seen non-deadline notifications`,
data: { count: result.count },
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
warnMissingNotificationTableOnce();
return {
success: true,
message: "Notification table missing. Read cleanup skipped.",
data: { count: 0 },
};
}
console.error("Error cleaning read non-deadline notifications:", error);
return {
success: false,
error:
error instanceof Error
? error.message
: "Failed to cleanup seen notifications",
};
}
}
/**
* Creates a new notification for a user
*
@@ -162,6 +202,9 @@ export class NotificationService {
limit: number = 10,
): Promise<NotificationResponse> {
try {
// Enforce retention policy on each read path.
await this.cleanupReadNonDeadline(userId);
const notifications = await prisma.notification.findMany({
where: {
userId,
@@ -220,6 +263,9 @@ export class NotificationService {
limit: number = 50,
): Promise<NotificationResponse> {
try {
// Enforce retention policy on each read path.
await this.cleanupReadNonDeadline(userId);
const notifications = await prisma.notification.findMany({
where: {
userId,
@@ -282,6 +328,21 @@ export class NotificationService {
data: { read: true },
});
if (notification.type !== "DEADLINE") {
await prisma.notification.delete({
where: { id: notificationId },
});
return {
success: true,
message: "Notification marked as read and auto-deleted",
data: {
id: notificationId,
deleted: true,
},
};
}
return {
success: true,
data: notification,
@@ -322,10 +383,13 @@ export class NotificationService {
data: { read: true },
});
const cleanup = await this.cleanupReadNonDeadline(userId);
const cleanupCount = cleanup.success ? cleanup.data?.count || 0 : 0;
return {
success: true,
message: `Marked ${result.count} notifications as read`,
data: { count: result.count },
message: `Marked ${result.count} notifications as read and auto-deleted ${cleanupCount} non-deadline items`,
data: { count: result.count, deletedCount: cleanupCount },
};
} catch (error) {
if (isNotificationTableMissingError(error)) {
@@ -446,6 +510,9 @@ export class NotificationService {
*/
static async getUnreadCount(userId: string): Promise<NotificationResponse> {
try {
// Enforce retention policy on count path as well.
await this.cleanupReadNonDeadline(userId);
const count = await prisma.notification.count({
where: {
userId,