PreRelease v1

This commit is contained in:
2026-03-25 13:52:45 +01:00
parent 94b0c68703
commit 6bf998a52a
56 changed files with 11427 additions and 847 deletions

890
lib/services/ai.service.ts Normal file
View 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 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:`;
}
/**
* 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}`);
}
}
}

View 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,
},
});
}
}

View 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",
};
}
}
}

View 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",
};
}
}

View 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];
}
}