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

View File

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

View File

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

View File

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

View File

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