PreRelease v1
This commit is contained in:
890
lib/services/ai.service.ts
Normal file
890
lib/services/ai.service.ts
Normal file
@@ -0,0 +1,890 @@
|
||||
// src/lib/services/ai.service.ts
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
// Read API key from environment once at module load.
|
||||
const API_KEY = process.env.AI_API_KEY;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("❌ AI_API_KEY is missing from environment variables");
|
||||
console.error("Please add AI_API_KEY to your .env file");
|
||||
throw new Error("AI_API_KEY is not configured");
|
||||
}
|
||||
|
||||
// Initialize Gemini
|
||||
const genAI = new GoogleGenerativeAI(API_KEY);
|
||||
|
||||
// Runtime options used by analysis.
|
||||
type AnalyzeOptions = {
|
||||
userId?: string;
|
||||
fileName?: string;
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
export class AIService {
|
||||
/**
|
||||
* Domain-specific guidance for contract Q&A.
|
||||
* This keeps responses focused on what matters most for each contract family.
|
||||
*/
|
||||
private static getContractTypeGuidance(type?: string | null): string {
|
||||
switch (type) {
|
||||
case "INSURANCE_AUTO":
|
||||
return "Focus on coverage scope, exclusions, deductible/franchise impact, claims workflow, and driver/vehicle obligations.";
|
||||
case "INSURANCE_HOME":
|
||||
return "Focus on covered perils, property limits, occupancy obligations, exclusions, and claims evidence requirements.";
|
||||
case "INSURANCE_HEALTH":
|
||||
return "Focus on reimbursement rules, waiting periods, provider network constraints, exclusions, and pre-authorization requirements.";
|
||||
case "INSURANCE_LIFE":
|
||||
return "Focus on beneficiary clauses, premium continuity, surrender/termination conditions, exclusions, and payout trigger conditions.";
|
||||
case "LOAN":
|
||||
return "Focus on repayment schedule, interest mechanics, default triggers, penalties, early repayment clauses, and covenant obligations.";
|
||||
case "CREDIT_CARD":
|
||||
return "Focus on APR/fees, billing cycle deadlines, late-payment penalties, credit limit terms, and dispute/chargeback conditions.";
|
||||
case "INVESTMENT":
|
||||
return "Focus on risk profile, fee structure, lock-in/liquidity constraints, reporting duties, and suitability/compliance implications.";
|
||||
default:
|
||||
return "Focus on obligations, financial exposure, compliance risks, termination conditions, and operational next steps.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze contract with Gemini 2.5 Flash.
|
||||
*
|
||||
* Pipeline overview:
|
||||
* 1) Download uploaded file
|
||||
* 2) Resolve MIME type safely
|
||||
* 3) Build adaptive prompt context from previous completed analyses
|
||||
* 4) Ask Gemini for strict JSON output
|
||||
* 5) Parse + normalize output
|
||||
* 6) Validate contract legitimacy and required fields
|
||||
* 7) Retry with correction hints if output is invalid
|
||||
* 8) Return canonical analysis object
|
||||
*
|
||||
* Supports both PDF and image files
|
||||
*/
|
||||
static async analyzeContract(fileUrl: string, options?: AnalyzeOptions) {
|
||||
try {
|
||||
const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2));
|
||||
|
||||
// Step 1: Download raw file bytes from storage URL.
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString("base64");
|
||||
|
||||
// Step 2: Resolve MIME type from response header and URL fallback.
|
||||
const mimeType = this.resolveMimeType(
|
||||
fileUrl,
|
||||
response.headers.get("content-type"),
|
||||
);
|
||||
|
||||
// Quick pre-validation to short-circuit obvious non-contract files.
|
||||
const precheck = await this.preValidateContract({
|
||||
base64,
|
||||
mimeType,
|
||||
fileName: options?.fileName,
|
||||
});
|
||||
|
||||
if (!precheck.isValidContract || precheck.confidence < 45) {
|
||||
throw new Error(
|
||||
`INVALID_CONTRACT:${precheck.reason || "Uploaded file is not recognized as a valid contract."}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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({
|
||||
adaptiveContext,
|
||||
fileName: options?.fileName,
|
||||
});
|
||||
|
||||
let previousRawResponse = "";
|
||||
let lastValidationError = "";
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
const startTime = Date.now();
|
||||
|
||||
const correctionHint =
|
||||
attempt === 1
|
||||
? ""
|
||||
: `\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 = result.response.text();
|
||||
if (!text) {
|
||||
lastValidationError = "No content in AI response";
|
||||
continue;
|
||||
}
|
||||
|
||||
previousRawResponse = text;
|
||||
|
||||
try {
|
||||
// Step 6: Parse and normalize output into canonical structure.
|
||||
const parsed = this.parseJsonResponse(text);
|
||||
const normalized = this.normalizeAnalysis(parsed);
|
||||
|
||||
// Step 7: Reject non-contract uploads with explicit error.
|
||||
this.assertValidContract(parsed, normalized);
|
||||
|
||||
console.log(
|
||||
"📄 Extracted text length:",
|
||||
normalized.extractedText.length,
|
||||
"chars",
|
||||
);
|
||||
console.log(
|
||||
"✅ Analysis completed in",
|
||||
((Date.now() - startTime) / 1000).toFixed(2),
|
||||
"seconds",
|
||||
);
|
||||
|
||||
return normalized;
|
||||
} catch (validationError: any) {
|
||||
// If validation fails, keep reason and retry with correction guidance.
|
||||
lastValidationError =
|
||||
validationError?.message || "Failed to parse model output";
|
||||
if (attempt === maxRetries) {
|
||||
throw new Error(lastValidationError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("AI analysis failed after retries.");
|
||||
} catch (error: any) {
|
||||
// Better error messages
|
||||
if (error.message?.includes("API key")) {
|
||||
throw new Error(
|
||||
"Invalid or missing Gemini API key. Check AI_API_KEY in your .env file",
|
||||
);
|
||||
} else if (error.message?.includes("INVALID_CONTRACT:")) {
|
||||
const reason = String(error.message)
|
||||
.replace("INVALID_CONTRACT:", "")
|
||||
.trim();
|
||||
throw new Error(
|
||||
reason || "Uploaded file is not recognized as a valid contract.",
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("not found") ||
|
||||
error.message?.includes("404")
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid Gemini model. Ensure 'gemini-2.5-flash' is available in your Google Cloud project.",
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("fetch") &&
|
||||
!error.message?.includes("generativelanguage")
|
||||
) {
|
||||
throw new Error(
|
||||
"Download failed. Check if the file URL is correct and accessible.",
|
||||
);
|
||||
} else if (error.message?.includes("JSON")) {
|
||||
console.error("❌ Raw response that failed to parse:", error);
|
||||
console.error("Full error message:", error.message);
|
||||
|
||||
// Help user understand what went wrong
|
||||
if (error.message?.includes("escaped quotes")) {
|
||||
throw new Error(
|
||||
"The contract contains special characters that corrupted the analysis. Try uploading a cleaner version.",
|
||||
);
|
||||
} else if (error.message?.includes("incomplete")) {
|
||||
throw new Error(
|
||||
"AI analysis failed to complete properly. This might be a large or complex contract. Try a smaller contract first.",
|
||||
);
|
||||
} else if (error.message?.includes("missing expected")) {
|
||||
throw new Error(
|
||||
"This doesn't appear to be a valid financial/insurance contract. Please upload a legitimate contract document.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Error parsing AI response. The response may not be valid JSON. Check console for details.",
|
||||
);
|
||||
}
|
||||
} else if (error.message?.includes("quota")) {
|
||||
throw new Error(
|
||||
"Limit exceeded. Your Gemini API quota may be exhausted. Check your Google Cloud Console for usage details.",
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Error analyzing contract: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build extraction prompt with strict schema + anti-hallucination instructions.
|
||||
*/
|
||||
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 3–4 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:`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve MIME type from HTTP headers first, then URL extension fallback.
|
||||
*/
|
||||
private static resolveMimeType(
|
||||
fileUrl: string,
|
||||
headerContentType: string | null,
|
||||
): string {
|
||||
const normalizedHeader = headerContentType?.toLowerCase() || "";
|
||||
if (normalizedHeader.startsWith("application/pdf")) {
|
||||
return "application/pdf";
|
||||
}
|
||||
if (normalizedHeader.startsWith("image/png")) {
|
||||
return "image/png";
|
||||
}
|
||||
if (normalizedHeader.startsWith("image/jpeg")) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (normalizedHeader.startsWith("image/webp")) {
|
||||
return "image/webp";
|
||||
}
|
||||
|
||||
const lowerUrl = fileUrl.toLowerCase();
|
||||
if (lowerUrl.includes(".pdf")) return "application/pdf";
|
||||
if (lowerUrl.includes(".png")) return "image/png";
|
||||
if (lowerUrl.includes(".jpg") || lowerUrl.includes(".jpeg"))
|
||||
return "image/jpeg";
|
||||
if (lowerUrl.includes(".webp")) return "image/webp";
|
||||
return "application/pdf"; // Default
|
||||
}
|
||||
|
||||
private static parseJsonResponse(text: string): unknown {
|
||||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||||
throw new Error("AI response is empty or invalid.");
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
// Check for common issues that indicate incomplete/corrupted response
|
||||
const responsePreview = cleanJson.substring(0, 200);
|
||||
console.log("🔍 AI Response preview:", responsePreview);
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 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.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!cleanJson.includes('"type"') && !cleanJson.includes('"title"')) {
|
||||
throw new Error(
|
||||
"Response is missing expected contract fields. It may not be a valid contract document.",
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to parse AI response as JSON: ${(fallbackError as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight contract validity pre-check.
|
||||
*
|
||||
* Goal: reject clearly invalid files quickly (invoice/photo/blank/non-legal doc)
|
||||
* before running heavier full extraction.
|
||||
*/
|
||||
private static async preValidateContract(input: {
|
||||
base64: string;
|
||||
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 result = await model.generateContent([
|
||||
`You are validating whether an uploaded document is a legal/financial contract.
|
||||
|
||||
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);
|
||||
const confidence = Number.isFinite(Number(maybe.confidence))
|
||||
? Math.max(0, Math.min(100, Math.round(Number(maybe.confidence))))
|
||||
: 0;
|
||||
const reason =
|
||||
typeof maybe.reason === "string" && maybe.reason.trim().length > 0
|
||||
? maybe.reason.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
isValidContract,
|
||||
confidence,
|
||||
reason,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
private static async buildAdaptiveContext(userId?: string): Promise<string> {
|
||||
// No user context means no adaptation baseline.
|
||||
if (!userId) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const examples = await prisma.contract.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: "COMPLETED",
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: 12,
|
||||
select: {
|
||||
type: true,
|
||||
provider: true,
|
||||
policyNumber: true,
|
||||
summary: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (examples.length < 2) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Small utility to get most frequent values from prior analyses.
|
||||
const count = (items: string[]) => {
|
||||
const bucket = new Map<string, number>();
|
||||
for (const item of items) {
|
||||
bucket.set(item, (bucket.get(item) ?? 0) + 1);
|
||||
}
|
||||
return [...bucket.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 4)
|
||||
.map(([value]) => value);
|
||||
};
|
||||
|
||||
const topTypes = count(
|
||||
examples
|
||||
.map((item) => item.type)
|
||||
.filter((value): value is NonNullable<typeof value> => value !== null)
|
||||
.map((value) => String(value)),
|
||||
);
|
||||
const topProviders = count(
|
||||
examples
|
||||
.map((item) => item.provider)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
|
||||
const policyPatterns = examples
|
||||
.map((item) => item.policyNumber)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.slice(0, 4)
|
||||
.map((value) => value.replace(/[A-Za-z0-9]/g, "X"));
|
||||
|
||||
const avgSummaryLength =
|
||||
examples
|
||||
.map((item) => item.summary?.length ?? 0)
|
||||
.reduce((sum, length) => sum + length, 0) / examples.length;
|
||||
|
||||
return `ADAPTIVE EXTRACTION CONTEXT FROM PREVIOUS DOCUMENTS:
|
||||
- Frequent contract types in this workspace: ${topTypes.join(", ") || "N/A"}
|
||||
- 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.
|
||||
|
||||
Use this context only as formatting guidance. Do not force it if current document content differs.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate contract legitimacy.
|
||||
*
|
||||
* Rejection rules:
|
||||
* - Model explicitly says document is not a contract
|
||||
* - Model confidence for validity is critically low
|
||||
* - Heuristic text signals suggest non-contract content
|
||||
*/
|
||||
private static assertValidContract(
|
||||
raw: any,
|
||||
normalized: NormalizedAnalysis,
|
||||
): void {
|
||||
const modelIsValid = raw?.contractValidation?.isValidContract;
|
||||
const confidenceRaw = Number(raw?.contractValidation?.confidence);
|
||||
const modelReason = String(raw?.contractValidation?.reason ?? "").trim();
|
||||
|
||||
const legalSignalRegex =
|
||||
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability/i;
|
||||
const hasLegalSignals = legalSignalRegex.test(normalized.extractedText);
|
||||
const hasStructuredSignal =
|
||||
Boolean(normalized.provider) ||
|
||||
Boolean(normalized.policyNumber) ||
|
||||
normalized.keyPoints.guarantees.length > 0 ||
|
||||
normalized.keyPoints.exclusions.length > 0 ||
|
||||
normalized.premium !== null;
|
||||
|
||||
if (modelIsValid === false) {
|
||||
throw new Error(
|
||||
`INVALID_CONTRACT:${modelReason || "Uploaded file is not recognized as a contract."}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Number.isFinite(confidenceRaw) && confidenceRaw < 45) {
|
||||
throw new Error(
|
||||
`INVALID_CONTRACT:${modelReason || "Contract confidence is too low. Please upload a clearer contract document."}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasLegalSignals && !hasStructuredSignal) {
|
||||
throw new Error(
|
||||
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that AI results have all required fields
|
||||
*/
|
||||
static validateAnalysis(data: any): boolean {
|
||||
try {
|
||||
// Validation uses same normalizer used in production flow.
|
||||
this.normalizeAnalysis(data);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse date string to Date object
|
||||
*/
|
||||
static parseDate(dateString: string | null | undefined): Date | undefined {
|
||||
if (!dateString) return undefined;
|
||||
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
if (isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
return date;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount
|
||||
*/
|
||||
static formatCurrency(amount: number | null | undefined): string {
|
||||
if (!amount) return "N/A";
|
||||
return new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
static async askAboutContract(input: {
|
||||
question: string;
|
||||
contract: {
|
||||
fileName: string;
|
||||
title?: string | null;
|
||||
type?: string | null;
|
||||
provider?: string | null;
|
||||
policyNumber?: string | null;
|
||||
startDate?: Date | string | null;
|
||||
endDate?: Date | string | null;
|
||||
premium?: number | null;
|
||||
summary?: string | null;
|
||||
keyPoints?: Record<string, unknown> | null;
|
||||
extractedText?: string | null;
|
||||
};
|
||||
}) {
|
||||
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)
|
||||
.trim();
|
||||
const contractTypeGuidance = this.getContractTypeGuidance(
|
||||
input.contract.type,
|
||||
);
|
||||
|
||||
const prompt = `You are a senior BFSI contract advisor.
|
||||
|
||||
Contract metadata:
|
||||
- File: ${input.contract.fileName}
|
||||
- Title: ${input.contract.title ?? "N/A"}
|
||||
- Type: ${input.contract.type ?? "N/A"}
|
||||
- Provider: ${input.contract.provider ?? "N/A"}
|
||||
- Policy Number: ${input.contract.policyNumber ?? "N/A"}
|
||||
- Start Date: ${input.contract.startDate ?? "N/A"}
|
||||
- End Date: ${input.contract.endDate ?? "N/A"}
|
||||
- Premium: ${input.contract.premium ?? "N/A"}
|
||||
|
||||
Summary:
|
||||
${input.contract.summary ?? "N/A"}
|
||||
|
||||
Key Points (JSON):
|
||||
${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)}
|
||||
|
||||
Extracted Text:
|
||||
${extractedTextSnippet || "N/A"}
|
||||
|
||||
User question:
|
||||
${input.question}
|
||||
|
||||
Instructions:
|
||||
- Write in clear, professional, business-oriented plain text.
|
||||
- Do NOT use markdown or special formatting symbols, including: **, __, #, *, -, backticks.
|
||||
- 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.
|
||||
- Adapt answer emphasis using this type guidance: ${contractTypeGuidance}
|
||||
- If information is missing, explicitly say: Information not found in the analyzed contract.
|
||||
- If the question asks about legal consequences or non-compliance, provide general legal context for EU/USA at a high level only.
|
||||
- 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.
|
||||
|
||||
Response structure:
|
||||
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:
|
||||
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();
|
||||
|
||||
if (!rawAnswer) {
|
||||
throw new Error("No response generated");
|
||||
}
|
||||
|
||||
const sanitizedAnswer = rawAnswer
|
||||
.replace(/\*\*/g, "")
|
||||
.replace(/__/g, "")
|
||||
.replace(/`/g, "")
|
||||
.replace(/^\s*#{1,6}\s*/gm, "")
|
||||
.replace(/^\s*[-*]\s+/gm, "")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
return sanitizedAnswer;
|
||||
} catch (error: any) {
|
||||
if (error.message?.includes("API key")) {
|
||||
throw new Error("Invalid or missing Gemini API key.");
|
||||
}
|
||||
throw new Error(`Error answering question: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
556
lib/services/contract.service.ts
Normal file
556
lib/services/contract.service.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
// src/lib/services/contract.service.ts
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { UTApi } from "uploadthing/server";
|
||||
import type {
|
||||
ContractFilters,
|
||||
ContractStats,
|
||||
ContractStatus,
|
||||
ContractType,
|
||||
} from "@/types/contract.types";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
const utapi = new UTApi();
|
||||
|
||||
export async function saveContract(data: {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}) {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
// Step 1: Create contract record (status: UPLOADED)
|
||||
const contract = await ContractService.create({
|
||||
...data,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Keep uploaded contracts pending until the user manually clicks Analyze.
|
||||
// Status stays as UPLOADED here.
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contract: {
|
||||
id: contract.id,
|
||||
fileName: contract.fileName,
|
||||
fileUrl: contract.fileUrl,
|
||||
status: contract.status,
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.error("❌ SAVE CONTRACT ERROR");
|
||||
console.error("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
console.error(error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export class ContractService {
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// CREATE CONTRACT
|
||||
// Called after UploadThing upload completes
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async create(data: {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
userId: string; // This is Clerk userId (clerkId)
|
||||
}) {
|
||||
// Find the internal database user by Clerk ID
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: data.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return await prisma.contract.create({
|
||||
data: {
|
||||
fileName: data.fileName,
|
||||
fileUrl: data.fileUrl,
|
||||
fileSize: data.fileSize,
|
||||
mimeType: data.mimeType,
|
||||
userId: user.id, // Use internal database User.id
|
||||
status: "UPLOADED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// UPDATE WITH AI RESULTS
|
||||
// Called after AI processing completes (Sprint 2)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async updateWithAIResults(
|
||||
id: string,
|
||||
aiResults: {
|
||||
title: string;
|
||||
type: ContractType;
|
||||
provider?: string;
|
||||
policyNumber?: string;
|
||||
startDate?: string | Date;
|
||||
endDate?: string | Date;
|
||||
premium?: number;
|
||||
extractedText: string;
|
||||
summary: string;
|
||||
keyPoints: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
// Convert date strings to proper ISO-8601 DateTime format
|
||||
const parseDate = (dateInput: string | Date | undefined): Date | null => {
|
||||
if (!dateInput) return null;
|
||||
|
||||
if (dateInput instanceof Date) {
|
||||
return dateInput;
|
||||
}
|
||||
|
||||
// If it's a date string (YYYY-MM-DD), convert to ISO DateTime
|
||||
if (typeof dateInput === "string") {
|
||||
const date = new Date(`${dateInput}T00:00:00Z`);
|
||||
return isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return await prisma.contract.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: aiResults.title,
|
||||
type: aiResults.type,
|
||||
provider: aiResults.provider,
|
||||
policyNumber: aiResults.policyNumber,
|
||||
startDate: parseDate(aiResults.startDate),
|
||||
endDate: parseDate(aiResults.endDate),
|
||||
premium: aiResults.premium,
|
||||
extractedText: aiResults.extractedText,
|
||||
summary: aiResults.summary,
|
||||
keyPoints: JSON.parse(JSON.stringify(aiResults.keyPoints)),
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// UPDATE STATUS
|
||||
// Used during processing pipeline
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async updateStatus(id: string, status: ContractStatus) {
|
||||
return await prisma.contract.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// MARK FAILED WITH REASON
|
||||
// Store user-visible reason in summary for failed analyses
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async markFailed(id: string, reason: string) {
|
||||
const safeReason = reason.trim().slice(0, 900);
|
||||
return await prisma.contract.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
summary: safeReason || "Analysis failed. Please try again.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET ALL CONTRACTS
|
||||
// With optional filtering and search
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async getAll(filters?: ContractFilters) {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
// Find the internal database user by Clerk ID
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: clerkUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
interface WhereClause {
|
||||
userId: string;
|
||||
type?: ContractType;
|
||||
status?: ContractStatus;
|
||||
OR?: Array<{
|
||||
title?: { contains: string; mode: "insensitive" };
|
||||
provider?: { contains: string; mode: "insensitive" };
|
||||
policyNumber?: { contains: string; mode: "insensitive" };
|
||||
fileName?: { contains: string; mode: "insensitive" };
|
||||
}>;
|
||||
}
|
||||
|
||||
const where: WhereClause = { userId: user.id };
|
||||
|
||||
// Filter by type
|
||||
if (filters?.type) {
|
||||
where.type = filters.type;
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (filters?.status) {
|
||||
where.status = filters.status;
|
||||
}
|
||||
|
||||
// Search across title, provider, policy number
|
||||
if (filters?.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: filters.search, mode: "insensitive" } },
|
||||
{ provider: { contains: filters.search, mode: "insensitive" } },
|
||||
{ policyNumber: { contains: filters.search, mode: "insensitive" } },
|
||||
{ fileName: { contains: filters.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
return await prisma.contract.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: "desc" },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET SINGLE CONTRACT
|
||||
// With authorization check
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async getById(id: string) {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
// Find the internal database user by Clerk ID
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: clerkUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
clerkId: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contract) {
|
||||
throw new Error("Contract not found");
|
||||
}
|
||||
|
||||
if (contract.userId !== user.id) {
|
||||
throw new Error("Unauthorized to access this contract");
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// DELETE CONTRACT
|
||||
// With authorization check and UploadThing file deletion
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async delete(id: string) {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
// Verify ownership and get contract details
|
||||
const contract = await this.getById(id);
|
||||
|
||||
// Extract file key from UploadThing URL
|
||||
// URL format: https://utfs.io/f/{fileKey}
|
||||
const fileKey = this.extractFileKeyFromUrl(contract.fileUrl);
|
||||
|
||||
// Delete file from UploadThing storage
|
||||
if (fileKey) {
|
||||
try {
|
||||
await utapi.deleteFiles(fileKey);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file from UploadThing:", error);
|
||||
// Continue with database deletion even if UploadThing deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete contract record from database
|
||||
return await prisma.contract.delete({
|
||||
where: { id },
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// HELPER: Extract file key from UploadThing URL
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
private static extractFileKeyFromUrl(url: string): string | null {
|
||||
try {
|
||||
// UploadThing URL format: https://utfs.io/f/{fileKey}
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split("/");
|
||||
// Get the last part which is the file key
|
||||
const fileKey = pathParts[pathParts.length - 1];
|
||||
return fileKey || null;
|
||||
} catch (error) {
|
||||
console.error("Failed to extract file key from URL:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET STATISTICS
|
||||
// Dashboard stats: total, active, expired, expiring soon
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async getStats(): Promise<ContractStats> {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
// Find the internal database user by Clerk ID
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: clerkUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const thirtyDaysFromNow = new Date(
|
||||
now.getTime() + 30 * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
const [total, active, expired, expiringSoon] = await Promise.all([
|
||||
// Total contracts
|
||||
prisma.contract.count({
|
||||
where: { userId: user.id },
|
||||
}),
|
||||
|
||||
// Active (completed and not expired)
|
||||
prisma.contract.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: "COMPLETED",
|
||||
OR: [
|
||||
{ endDate: { gte: now } },
|
||||
{ endDate: null }, // No end date
|
||||
],
|
||||
},
|
||||
}),
|
||||
|
||||
// Expired (end date in the past)
|
||||
prisma.contract.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
endDate: {
|
||||
lt: now,
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Expiring in next 30 days
|
||||
prisma.contract.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
endDate: {
|
||||
gte: now,
|
||||
lte: thirtyDaysFromNow,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return { total, active, expired, expiringSoon };
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET EXPIRING CONTRACTS
|
||||
// Get contracts expiring within X days
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async getExpiring(daysAhead: number = 30) {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: clerkUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const futureDate = new Date(
|
||||
now.getTime() + daysAhead * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
|
||||
return await prisma.contract.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
status: "COMPLETED",
|
||||
endDate: {
|
||||
gte: now,
|
||||
lte: futureDate,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
endDate: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET CONTRACTS BY TYPE
|
||||
// Group contracts by type for analytics
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async getByType() {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: clerkUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const contracts = await prisma.contract.groupBy({
|
||||
by: ["type"],
|
||||
where: { userId: user.id },
|
||||
_count: {
|
||||
type: true,
|
||||
},
|
||||
});
|
||||
|
||||
return contracts.map((item) => ({
|
||||
type: item.type,
|
||||
count: item._count.type,
|
||||
}));
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// CHECK IF USER OWNS CONTRACT
|
||||
// Quick ownership check (used for authorization)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async isOwner(contractId: string, userId: string): Promise<boolean> {
|
||||
const contract = await prisma.contract.findUnique({
|
||||
where: { id: contractId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
return contract?.userId === userId;
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// GET RECENT CONTRACTS
|
||||
// For dashboard "recent activity"
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async getRecent(limit: number = 5) {
|
||||
const { userId: clerkUserId } = await auth();
|
||||
if (!clerkUserId) throw new Error("Unauthorized");
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { clerkId: clerkUserId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return await prisma.contract.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
fileName: true,
|
||||
type: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// UPDATE PARTIAL
|
||||
// Update specific fields (useful for editing)
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
static async updatePartial(
|
||||
id: string,
|
||||
data: {
|
||||
title?: string;
|
||||
type?: ContractType;
|
||||
provider?: string;
|
||||
policyNumber?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
premium?: number;
|
||||
},
|
||||
) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) throw new Error("Unauthorized");
|
||||
|
||||
// Verify ownership
|
||||
await this.getById(id);
|
||||
|
||||
return await prisma.contract.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by Clerk ID
|
||||
*
|
||||
* Used to retrieve internal database user ID from Clerk authentication ID
|
||||
* This is necessary because:
|
||||
* - Clerk returns clerkId after authentication
|
||||
* - Database stores internal User.id (CUID)
|
||||
* - Contract operations need the internal User.id
|
||||
*
|
||||
* @param clerkId - Clerk authentication user ID
|
||||
* @returns Internal database User object or null if not found
|
||||
*/
|
||||
static async getUserByClerkId(clerkId: string) {
|
||||
return await prisma.user.findUnique({
|
||||
where: { clerkId },
|
||||
select: {
|
||||
id: true,
|
||||
clerkId: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
634
lib/services/notification.service.ts
Normal file
634
lib/services/notification.service.ts
Normal file
@@ -0,0 +1,634 @@
|
||||
/**
|
||||
* Notification Service
|
||||
*
|
||||
* Handles all notification-related operations including:
|
||||
* - Creating notifications for user actions
|
||||
* - Retrieving notifications with filtering
|
||||
* - Marking notifications as read
|
||||
* - Checking for upcoming contract renewals/deadlines
|
||||
* - Cleaning up expired notifications
|
||||
*
|
||||
* Integrates with Prisma ORM for database operations.
|
||||
* Supports multiple notification types: SUCCESS, WARNING, ERROR, INFO, DEADLINE
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
let hasWarnedMissingNotificationTable = false;
|
||||
|
||||
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")
|
||||
);
|
||||
};
|
||||
|
||||
const warnMissingNotificationTableOnce = () => {
|
||||
if (hasWarnedMissingNotificationTable) return;
|
||||
hasWarnedMissingNotificationTable = true;
|
||||
console.warn(
|
||||
"Notification table is missing. Notification features are temporarily disabled until schema is synced.",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Notification type for creating new notifications
|
||||
*/
|
||||
interface CreateNotificationInput {
|
||||
userId: string;
|
||||
type: "SUCCESS" | "WARNING" | "ERROR" | "INFO" | "DEADLINE";
|
||||
title: string;
|
||||
message: string;
|
||||
contractId?: string;
|
||||
actionType?: string;
|
||||
actionData?: Record<string, any>;
|
||||
icon?: string;
|
||||
expiresIn?: number; // milliseconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Response type for notification operations
|
||||
*/
|
||||
interface NotificationResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: any;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Creates a new notification for a user
|
||||
*
|
||||
* @param input - Notification creation parameters
|
||||
* @returns Promise with success status and notification data
|
||||
*
|
||||
* Steps:
|
||||
* 1. Calculate expiration time if provided (default: 30 days)
|
||||
* 2. Insert notification into database
|
||||
* 3. Return created notification with metadata
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* await NotificationService.create({
|
||||
* userId: "user123",
|
||||
* type: "SUCCESS",
|
||||
* title: "Contract Uploaded",
|
||||
* message: "Your contract has been uploaded successfully"
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
static async create(
|
||||
input: CreateNotificationInput,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
// Calculate expiration time: default to 30 days if not specified
|
||||
const expiresAt = input.expiresIn
|
||||
? new Date(Date.now() + input.expiresIn)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
|
||||
|
||||
// Create notification in database
|
||||
const notification = await prisma.notification.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
type: input.type,
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
contractId: input.contractId ?? undefined,
|
||||
actionType: input.actionType ?? undefined,
|
||||
actionData: input.actionData ?? undefined,
|
||||
icon: input.icon ?? undefined,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: notification,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification skipped: table not available yet.",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error creating notification:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to create notification",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all unread notifications for a user
|
||||
*
|
||||
* @param userId - The user's ID
|
||||
* @param limit - Maximum number of notifications to return (default: 10)
|
||||
* @returns Promise with array of notifications sorted by creation date (newest first)
|
||||
*
|
||||
* Steps:
|
||||
* 1. Query database for unread notifications
|
||||
* 2. Filter out expired notifications (expiresAt < now)
|
||||
* 3. Sort by creation date (descending)
|
||||
* 4. Limit results to specified count
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* const notifications = await NotificationService.getUnread("user123", 15);
|
||||
* ```
|
||||
*/
|
||||
static async getUnread(
|
||||
userId: string,
|
||||
limit: number = 10,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
read: false,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
include: {
|
||||
contract: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
fileName: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: notifications,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error fetching unread notifications:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch notifications",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all notifications for a user (read and unread)
|
||||
*
|
||||
* @param userId - The user's ID
|
||||
* @param limit - Maximum number of notifications to return (default: 50)
|
||||
* @returns Promise with array of all notifications sorted by creation date
|
||||
*
|
||||
* Used for displaying complete notification history/log
|
||||
*/
|
||||
static async getAll(
|
||||
userId: string,
|
||||
limit: number = 50,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
include: {
|
||||
contract: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
fileName: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: notifications,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error fetching all notifications:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch notifications",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a notification as read
|
||||
*
|
||||
* @param notificationId - The ID of the notification to mark as read
|
||||
* @returns Promise with success status
|
||||
*
|
||||
* Steps:
|
||||
* 1. Update notification read flag to true
|
||||
* 2. Return updated notification
|
||||
*/
|
||||
static async markAsRead(
|
||||
notificationId: string,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
const notification = await prisma.notification.update({
|
||||
where: { id: notificationId },
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: notification,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification table missing. Mark-as-read skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error marking notification as read:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to mark notification as read",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all unread notifications as read for a user
|
||||
*
|
||||
* @param userId - The user's ID
|
||||
* @returns Promise with count of updated notifications
|
||||
*/
|
||||
static async markAllAsRead(userId: string): Promise<NotificationResponse> {
|
||||
try {
|
||||
const result = await prisma.notification.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
read: false,
|
||||
},
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Marked ${result.count} notifications as read`,
|
||||
data: { count: result.count },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification table missing. Mark-all-as-read skipped.",
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error marking all notifications as read:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to mark notifications as read",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a notification
|
||||
*
|
||||
* @param notificationId - The ID of the notification to delete
|
||||
* @returns Promise with success status
|
||||
*/
|
||||
static async delete(notificationId: string): Promise<NotificationResponse> {
|
||||
try {
|
||||
await prisma.notification.delete({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification deleted successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification table missing. Delete skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error deleting notification:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to delete notification",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up expired notifications from the database
|
||||
*
|
||||
* Called periodically to remove old notifications
|
||||
* Only deletes notifications where expiresAt < current time
|
||||
*
|
||||
* @returns Promise with count of deleted notifications
|
||||
*
|
||||
* Example: Run daily via cron job or scheduled background task
|
||||
*/
|
||||
static async cleanupExpired(): Promise<NotificationResponse> {
|
||||
try {
|
||||
const result = await prisma.notification.deleteMany({
|
||||
where: {
|
||||
expiresAt: {
|
||||
lt: new Date(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleaned up ${result.count} expired notifications`,
|
||||
data: { count: result.count },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification table missing. Cleanup skipped.",
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error cleaning up expired notifications:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to cleanup notifications",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets unread notification count for a user
|
||||
*
|
||||
* Used for badge display on notification icon
|
||||
*
|
||||
* @param userId - The user's ID
|
||||
* @returns Promise with unread count
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* const count = await NotificationService.getUnreadCount("user123");
|
||||
* // Display badge with count on notification icon
|
||||
* ```
|
||||
*/
|
||||
static async getUnreadCount(userId: string): Promise<NotificationResponse> {
|
||||
try {
|
||||
const count = await prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
read: false,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { count },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error getting unread count:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to get unread count",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for upcoming contract renewals/expirations and creates notifications
|
||||
*
|
||||
* Scans all contracts for a user and creates DEADLINE notifications for:
|
||||
* - 30 days before expiration (CRITICAL)
|
||||
* - 15 days before expiration (WARNING)
|
||||
* - 7 days before expiration (URGENT)
|
||||
*
|
||||
* @param userId - The user's ID
|
||||
* @returns Promise with count of created notifications
|
||||
*
|
||||
* Steps:
|
||||
* 1. Query all COMPLETED contracts with endDate for the user
|
||||
* 2. Calculate days until expiration
|
||||
* 3. Create notification if contract expiring in 30, 15, or 7 days
|
||||
* 4. Check for existing notification to avoid duplicates
|
||||
* 5. Return summary of created notifications
|
||||
*
|
||||
* Example: Run daily via cron job
|
||||
* ```typescript
|
||||
* await NotificationService.checkUpcomingDeadlines("user123");
|
||||
* ```
|
||||
*/
|
||||
static async checkUpcomingDeadlines(
|
||||
userId: string,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// Query all contracts with endDate for this user
|
||||
const contracts = await prisma.contract.findMany({
|
||||
where: {
|
||||
userId,
|
||||
status: "COMPLETED",
|
||||
endDate: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
endDate: true,
|
||||
provider: true,
|
||||
},
|
||||
});
|
||||
|
||||
const createdNotifications: string[] = [];
|
||||
|
||||
// Process each contract
|
||||
for (const contract of contracts) {
|
||||
if (!contract.endDate) continue;
|
||||
|
||||
// Calculate days until expiration
|
||||
const contractEnd = new Date(contract.endDate);
|
||||
contractEnd.setHours(0, 0, 0, 0);
|
||||
const daysUntilExpiration = Math.ceil(
|
||||
(contractEnd.getTime() - today.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
// Define deadline thresholds and notification levels
|
||||
let shouldNotify = false;
|
||||
let level = "";
|
||||
|
||||
if (daysUntilExpiration === 7) {
|
||||
shouldNotify = true;
|
||||
level = "URGENT";
|
||||
} else if (daysUntilExpiration === 15) {
|
||||
shouldNotify = true;
|
||||
level = "WARNING";
|
||||
} else if (daysUntilExpiration === 30) {
|
||||
shouldNotify = true;
|
||||
level = "CRITICAL";
|
||||
}
|
||||
|
||||
if (shouldNotify) {
|
||||
// Check if notification already exists for this deadline
|
||||
const existingNotification = await prisma.notification.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
contractId: contract.id,
|
||||
actionType: `RENEWAL_${level}`,
|
||||
createdAt: {
|
||||
gte: new Date(today.getTime() - 24 * 60 * 60 * 1000), // Within last 24 hours
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Only create if not already notified today
|
||||
if (!existingNotification) {
|
||||
const notificationTitle =
|
||||
level === "CRITICAL"
|
||||
? `🔴 Contract Expiring in 30 Days`
|
||||
: level === "WARNING"
|
||||
? `🟠 Contract Expiring in 15 Days`
|
||||
: `🟡 Contract Expiring in 7 Days`;
|
||||
|
||||
const notificationMessage =
|
||||
level === "CRITICAL"
|
||||
? `${contract.title} from ${contract.provider} will expire on ${contractEnd.toLocaleDateString()}. Time to renew!`
|
||||
: level === "WARNING"
|
||||
? `${contract.title} from ${contract.provider} expires in 15 days. Consider scheduling renewal.`
|
||||
: `${contract.title} from ${contract.provider} expires in 7 days. Renew now!`;
|
||||
|
||||
const result = await this.create({
|
||||
userId,
|
||||
type: "DEADLINE",
|
||||
title: notificationTitle,
|
||||
message: notificationMessage,
|
||||
contractId: contract.id,
|
||||
actionType: `RENEWAL_${level}`,
|
||||
icon: level === "CRITICAL" ? "AlertCircle" : "AlertTriangle",
|
||||
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
|
||||
actionData: {
|
||||
level,
|
||||
daysUntilExpiration,
|
||||
expirationDate: contractEnd.toISOString(),
|
||||
contractTitle: contract.title,
|
||||
contractProvider: contract.provider,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
createdNotifications.push(contract.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Created ${createdNotifications.length} deadline notifications`,
|
||||
data: {
|
||||
count: createdNotifications.length,
|
||||
contractIds: createdNotifications,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification table missing. Deadline scan skipped.",
|
||||
data: { count: 0, contractIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error checking upcoming deadlines:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error ? error.message : "Failed to check deadlines",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
367
lib/services/stats.service.ts
Normal file
367
lib/services/stats.service.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
}
|
||||
69
lib/services/storage.service.ts
Normal file
69
lib/services/storage.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/lib/services/storage.service.ts
|
||||
|
||||
export class StorageService {
|
||||
// Validate file type
|
||||
static isValidFileType(file: File): boolean {
|
||||
const allowedTypes = [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
];
|
||||
|
||||
return allowedTypes.includes(file.type);
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
static isValidFileSize(file: File): boolean {
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
return file.size <= maxSize;
|
||||
}
|
||||
|
||||
// Extract filename from UploadThing URL
|
||||
static extractFileName(url: string): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const path = urlObj.pathname;
|
||||
return path.split("/").pop() || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Extract file key from UploadThing URL for deletion
|
||||
// URL format: https://utfs.io/f/{fileKey}
|
||||
static extractFileKey(url: string): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathParts = urlObj.pathname.split("/");
|
||||
const fileKey = pathParts[pathParts.length - 1];
|
||||
return fileKey || null;
|
||||
} catch (error) {
|
||||
console.error("Failed to extract file key from URL:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if URL is from UploadThing
|
||||
static isUploadThingUrl(url: string): boolean {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
return (
|
||||
urlObj.hostname.includes("utfs.io") ||
|
||||
urlObj.hostname.includes("uploadthing")
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Format file size
|
||||
static formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user