PreRelease v1
This commit is contained in:
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"prisma.pinToPrisma6": true
|
||||
}
|
||||
274
DASHBOARD_REDESIGN.md
Normal file
274
DASHBOARD_REDESIGN.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Complete Dashboard Redesign - Implementation Summary
|
||||
|
||||
## 🎯 Architecture Overview
|
||||
|
||||
The dashboard has been reorganized into two distinct routes with a persistent navigation sidebar:
|
||||
|
||||
### Route Structure
|
||||
|
||||
```
|
||||
/dashboard → Analytics Hub (Stats & Charts)
|
||||
/contacts → Contracts Manager (Upload & Management)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 New /dashboard Route Features
|
||||
|
||||
### 1. **Stats Components**
|
||||
|
||||
- **6 Key Metric Cards** displaying:
|
||||
- Total Contracts (with trend indicator)
|
||||
- Analyzed Contracts (with analysis rate %)
|
||||
- In Progress (with processing indicator)
|
||||
- Failed Contracts (with alert)
|
||||
- Average Premium ($)
|
||||
- Analysis Success Rate (%)
|
||||
|
||||
- **Visual Design:**
|
||||
- Gradient backgrounds (blue, green, amber, red, purple, cyan)
|
||||
- Icon indicators for each metric
|
||||
- Trend indicators with directional arrows
|
||||
- Smooth animations on load
|
||||
|
||||
### 2. **Advanced Charts (using Recharts)**
|
||||
|
||||
- **Upload Trends Chart** (Area Chart)
|
||||
- 30-day trend visualization
|
||||
- Interactive tooltips
|
||||
- Gradient fill effects
|
||||
- **Contract Type Distribution** (Bar Chart)
|
||||
- Visual breakdown by contract type
|
||||
- Color-coded bars
|
||||
- Responsive design
|
||||
- **Contract Status Overview** (Pie Chart)
|
||||
- Visual representation of processing statuses
|
||||
- Color-coded segments
|
||||
- Interactive labels
|
||||
- **Distribution Radar Chart** (Coming in enhanced version)
|
||||
- Multi-dimensional data visualization
|
||||
|
||||
### 3. **Key Insights Section**
|
||||
|
||||
- **Analysis Efficiency Card**
|
||||
- Success rate progress bar with animation
|
||||
- Real-time calculation from database
|
||||
- **Processing Queue Card**
|
||||
- In-progress contracts count
|
||||
- Failed contracts indicator
|
||||
- **Premium Metrics Card**
|
||||
- Total premium analysis
|
||||
- Number of analyzed contracts
|
||||
|
||||
### 4. **Quick Actions**
|
||||
|
||||
- **Upload New Contract** → Links to /contacts
|
||||
- **View All Contracts** → Links to /contacts with count
|
||||
|
||||
---
|
||||
|
||||
## 🗂️ New /contacts Route Features
|
||||
|
||||
### 1. **Professional Header**
|
||||
|
||||
- Breadcrumb navigation back to dashboard
|
||||
- Gradient title with descriptive subtitle
|
||||
- Quick stats badges highlighting key features
|
||||
|
||||
### 2. **Contract Upload Section**
|
||||
|
||||
- Drag-and-drop file upload
|
||||
- AI-powered analysis indication
|
||||
- Real-time upload feedback
|
||||
|
||||
### 3. **Contracts List & Management**
|
||||
|
||||
- Existing contracts display with new styling
|
||||
- Analysis status indicators
|
||||
- Ask questions feature (existing, now integrated)
|
||||
- Contract details modal
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design System Enhancements
|
||||
|
||||
### Visual Elements
|
||||
|
||||
- **Animated Gradient Backgrounds**
|
||||
- Floating blob animations in background
|
||||
- Smooth color transitions
|
||||
- Dark mode optimized
|
||||
|
||||
- **Color Palette**
|
||||
- Primary: Blue (#3B82F6)
|
||||
- Accent: Gradient colors
|
||||
- Semantic colors for status indicators
|
||||
|
||||
- **Typography**
|
||||
- Bold headlines with gradient text
|
||||
- Clear visual hierarchy
|
||||
- Responsive font sizing
|
||||
|
||||
- **Animations**
|
||||
- Fade-in effects on page load
|
||||
- Smooth transitions between states
|
||||
- Motion/Framer Motion integration
|
||||
- Staggered card animations
|
||||
|
||||
### Components
|
||||
|
||||
- Shadcn/UI for consistent design
|
||||
- Custom stat cards with gradients
|
||||
- Backdrop blur effects
|
||||
- Border styling with transparency
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Technical Implementation
|
||||
|
||||
### New Files Created
|
||||
|
||||
1. **Services**
|
||||
- `lib/services/stats.service.ts` → Database queries for analytics
|
||||
- `lib/actions/stats.action.ts` → Server action wrapper
|
||||
|
||||
2. **Components**
|
||||
- `components/views/dashboard/stat-cards.tsx` → Metric cards
|
||||
- `components/views/dashboard/charts.tsx` → Recharts integration
|
||||
- `components/views/dashboard/navigation.tsx` → Sidebar navigation
|
||||
- `components/views/dashboard/contacts-header.tsx` → Contacts page header
|
||||
|
||||
3. **Routes**
|
||||
- `app/(dashboard)/contacts/layout.tsx` → Contacts route layout
|
||||
- `app/(dashboard)/contacts/page.tsx` → Contacts page with upload & list
|
||||
|
||||
4. **Updated Files**
|
||||
- `app/(dashboard)/layout.tsx` → Sidebar integration
|
||||
- `app/(dashboard)/dashboard/page.tsx` → New analytics dashboard
|
||||
|
||||
---
|
||||
|
||||
## 📈 Data & Analytics
|
||||
|
||||
### Stats Service Features
|
||||
|
||||
```typescript
|
||||
{
|
||||
stats: {
|
||||
totalContracts: number
|
||||
analyzedContracts: number
|
||||
processingContracts: number
|
||||
failedContracts: number
|
||||
analysisRate: percentage
|
||||
},
|
||||
chartData: {
|
||||
byType: Array<{type, count}>
|
||||
byStatus: Array<{status, count}>
|
||||
trends: Array<{date, count}> // Last 30 days
|
||||
},
|
||||
premiumInfo: {
|
||||
averagePremium: number
|
||||
totalPremium: number
|
||||
count: number
|
||||
},
|
||||
recentContracts: Array<{id, title, type, createdAt, premium}>
|
||||
}
|
||||
```
|
||||
|
||||
### Database Queries
|
||||
|
||||
- Aggregated counts by status, type, and date
|
||||
- Premium statistics (avg, sum, count)
|
||||
- 30-day trend analysis
|
||||
- Recent contracts ranking
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Navigation System
|
||||
|
||||
### Sidebar Features
|
||||
|
||||
- **Logo with brand identity**
|
||||
- **Two main navigation items**
|
||||
- Analytics (Dashboard)
|
||||
- Contracts (Management)
|
||||
- **Theme toggle** (Light/Dark mode)
|
||||
- **Sign out button**
|
||||
- **Responsive active states**
|
||||
- **Smooth transitions and animations**
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Performance & UX
|
||||
|
||||
### Optimizations
|
||||
|
||||
- Client-side state management for smooth interactions
|
||||
- Server-side analytics computation
|
||||
- Lazy loading of components
|
||||
- Efficient database queries with aggregation
|
||||
- Responsive design for all screen sizes
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Semantic HTML structure
|
||||
- ARIA labels on icons
|
||||
- Clear visual hierarchy
|
||||
- High contrast ratios
|
||||
- Keyboard navigation support
|
||||
|
||||
---
|
||||
|
||||
## 📱 Responsive Design
|
||||
|
||||
- **Desktop (1024px+)**: Full sidebar + expanded content
|
||||
- **Tablet (768px-1023px)**: Flexible grid layouts
|
||||
- **Mobile**: Responsive charts and card layouts
|
||||
|
||||
---
|
||||
|
||||
## 🎬 Next Steps & Enhancements
|
||||
|
||||
### Future Improvements
|
||||
|
||||
1. **Export Reports** → PDF/Excel export functionality
|
||||
2. **Advanced Filtering** → Filter contracts by date, type, status
|
||||
3. **Custom Dashboards** → User-customizable dashboard layouts
|
||||
4. **Real-time WebSocket Updates** → Live processing status
|
||||
5. **Performance Optimization** → Data caching and infinite scroll
|
||||
6. **Dark Mode Enhancements** → More sophisticated theme system
|
||||
7. **Mobile App** → Native mobile experience
|
||||
|
||||
---
|
||||
|
||||
## 🔐 Security & Authorization
|
||||
|
||||
- User ID verification in all server actions
|
||||
- Clerk authentication integration
|
||||
- Database queries filtered by userId
|
||||
- Server-side data serialization
|
||||
|
||||
---
|
||||
|
||||
## 📦 Dependencies Used
|
||||
|
||||
- `recharts` (v3.7.0) - Chart visualizations
|
||||
- `motion` - Animations
|
||||
- `lucide-react` - Icons
|
||||
- `shadcn/ui` - UI components
|
||||
- `@clerk/nextjs` - Authentication
|
||||
|
||||
---
|
||||
|
||||
## ✅ Testing Checklist
|
||||
|
||||
- [x] All TypeScript types compile correctly
|
||||
- [x] No console errors on load
|
||||
- [x] Navigation between routes works smoothly
|
||||
- [x] Stats query returns valid data
|
||||
- [x] Charts render with sample data
|
||||
- [x] Responsive design on mobile/tablet
|
||||
- [x] Dark mode toggle functions
|
||||
- [x] Animations perform smoothly
|
||||
- [x] Sidebar navigation is responsive
|
||||
- [x] User authentication required
|
||||
506
NOTIFICATION_IMPLEMENTATION_SUMMARY.md
Normal file
506
NOTIFICATION_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# 🔔 Notification System Implementation - Complete Guide
|
||||
|
||||
## ✨ What Was Implemented
|
||||
|
||||
You've successfully enabled Option 2: **Renewal and Deadline Assistant** with comprehensive notification system.
|
||||
|
||||
### 🎯 Key Features
|
||||
|
||||
1. **Toast Notifications** (Sonner)
|
||||
- ✅ Contract uploaded successfully
|
||||
- ✅ Contract analyzed successfully (or error with reason)
|
||||
- ✅ Contract deleted successfully
|
||||
- ❌ Error messages with detailed feedback
|
||||
- 🔔 Deadline alerts for upcoming expirations
|
||||
|
||||
2. **Persistent Notifications Database**
|
||||
- All notifications are stored permanently
|
||||
- 5 notification types: SUCCESS, ERROR, WARNING, INFO, DEADLINE
|
||||
- Notifications linked to specific contracts
|
||||
- Auto-expiration after 30 days (configurable)
|
||||
|
||||
3. **Notification Bar UI**
|
||||
- Beautiful bell icon with unread count badge
|
||||
- Dropdown showing recent 15 notifications
|
||||
- Type-specific icons and colors
|
||||
- Action buttons to mark as read or delete
|
||||
- Time formatting (e.g., "2m ago", "1h ago")
|
||||
- Empty state when no notifications
|
||||
- Auto-refresh every 30 seconds when open
|
||||
|
||||
4. **Deadline Detection & Alerts**
|
||||
- 🔴 **30 Days Before Expiration**: CRITICAL notification
|
||||
- 🟠 **15 Days Before Expiration**: WARNING notification
|
||||
- 🟡 **7 Days Before Expiration**: URGENT notification
|
||||
- Daily automatic check on dashboard load
|
||||
- Smart deduplication (max 1 notification per threshold per day)
|
||||
|
||||
5. **Well-Documented Code**
|
||||
- 1000+ lines of comprehensive inline comments
|
||||
- JSDoc comments for all functions
|
||||
- Step-by-step explanations of processing pipelines
|
||||
- Examples and usage patterns
|
||||
|
||||
## 📁 Files Created/Modified
|
||||
|
||||
### New Files Created
|
||||
|
||||
```
|
||||
✨ lib/services/notification.service.ts (580 lines) - Core notification logic
|
||||
✨ lib/actions/notification.action.ts (340 lines) - Server actions for notifications
|
||||
✨ components/views/dashboard/notification-bar.tsx (490 lines) - Notification UI component
|
||||
✨ hooks/useNotifications.ts (220 lines) - React hook for toast + notifications
|
||||
✨ NOTIFICATION_SYSTEM_SETUP.md (400 lines) - Detailed setup guide
|
||||
✨ setup-notifications.sh (30 lines) - Automated setup script
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
|
||||
```
|
||||
📝 prisma/schema.prisma - Added Notification model, NotificationType enum
|
||||
📝 lib/actions/contract.action.ts - Added notifications on upload/analyze/delete
|
||||
📝 lib/services/contract.service.ts - Added getUserByClerkId() method
|
||||
📝 components/views/dashboard/navigation.tsx - Added NotificationBar component
|
||||
📝 app/(dashboard)/dashboard/page.tsx - Added checkDeadlineNotifications() call
|
||||
```
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Run Database Migration
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name add_notifications
|
||||
```
|
||||
|
||||
This creates:
|
||||
|
||||
- `Notification` table with indexes
|
||||
- `NotificationType` enum (SUCCESS, WARNING, ERROR, INFO, DEADLINE)
|
||||
- Relations between User, Contract, and Notification
|
||||
|
||||
### 2. Generate Prisma Client
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
Or use the automated setup script:
|
||||
|
||||
```bash
|
||||
bash setup-notifications.sh
|
||||
```
|
||||
|
||||
### 3. Start Development Server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📊 Architecture Overview
|
||||
|
||||
### Notification Flow
|
||||
|
||||
```
|
||||
User Action (upload/analyze/delete)
|
||||
↓
|
||||
Contract Server Action
|
||||
↓
|
||||
├─ Execute operation (save/analyze/delete)
|
||||
├─ Create Sonner toast (immediate UI feedback)
|
||||
└─ Create database notification (persistent)
|
||||
↓
|
||||
Notification Service
|
||||
↓
|
||||
├─ Store in database
|
||||
├─ Assign expiration time
|
||||
└─ Link to contract
|
||||
↓
|
||||
Notification Bar
|
||||
↓
|
||||
├─ Display bell icon with unread count
|
||||
├─ Show in dropdown when clicked
|
||||
└─ Allow user interaction (mark read, delete)
|
||||
```
|
||||
|
||||
### Deadline Notification Flow
|
||||
|
||||
```
|
||||
Dashboard Page Load
|
||||
↓
|
||||
checkDeadlineNotifications() called
|
||||
↓
|
||||
Notification Service
|
||||
↓
|
||||
├─ Query all user contracts with endDate
|
||||
├─ Calculate days until expiration
|
||||
├─ Check if 30, 15, or 7 days away
|
||||
├─ Avoid duplicate notifications
|
||||
└─ Create deadline notifications
|
||||
↓
|
||||
Stored in database
|
||||
↓
|
||||
Display in Notification Bar
|
||||
```
|
||||
|
||||
## 💻 Usage Examples
|
||||
|
||||
### Example 1: Upload Contract
|
||||
|
||||
**User Action**: Click upload button, select file
|
||||
**Flow**:
|
||||
|
||||
1. File uploaded to UploadThing
|
||||
2. `saveContract()` server action triggered
|
||||
3. Toast appears: "📄 Contract Uploaded"
|
||||
4. Notification created and stored in database
|
||||
5. User can see notification in bell icon dropdown
|
||||
|
||||
### Example 2: Analyze Contract
|
||||
|
||||
**User Action**: Click "Analyze" button on uploaded contract
|
||||
**Flow**:
|
||||
|
||||
1. Status changes to PROCESSING with spinner
|
||||
2. Toast appears: "⏳ Analyzing Contract"
|
||||
3. AI analyzes the file
|
||||
4. On Success:
|
||||
- Toast: "✅ Contract Analyzed" with details
|
||||
- Database notification created
|
||||
- Contract details populate
|
||||
5. On Error:
|
||||
- Toast: "❌ Analysis Failed" with reason
|
||||
- Database notification created with error
|
||||
- Invalid contract modal shown (if applicable)
|
||||
|
||||
### Example 3: Delete Contract
|
||||
|
||||
**User Action**: Click delete, confirm in dialog
|
||||
**Flow**:
|
||||
|
||||
1. Delete confirmation modal appears
|
||||
2. On confirm:
|
||||
- File deleted from UploadThing storage
|
||||
- Contract deleted from database
|
||||
- Toast: "🗑️ Contract Deleted"
|
||||
- Notification created and stored
|
||||
3. Contracts list refreshes automatically
|
||||
|
||||
### Example 4: Deadline Alert
|
||||
|
||||
**Trigger**: Dashboard page load + 30/15/7 days before expiration
|
||||
**Flow**:
|
||||
|
||||
1. System queries all user contracts with endDate
|
||||
2. Calculates days until each expiration
|
||||
3. For contracts expiring in 30, 15, or 7 days:
|
||||
- Creates deadline notification
|
||||
- Avoids duplicates (max 1 per threshold per day)
|
||||
4. Notifications appear in bell icon dropdown
|
||||
5. Toast displayed if first time that day
|
||||
|
||||
## 🔔 Notification Types & Colors
|
||||
|
||||
| Type | Color | Icon | Use Case |
|
||||
| -------- | --------- | ------------- | ------------------------------------ |
|
||||
| SUCCESS | Green ✅ | CheckCircle2 | Contract uploaded, analyzed, deleted |
|
||||
| ERROR | Red ❌ | AlertCircle | Upload failed, analysis failed |
|
||||
| WARNING | Yellow ⚠️ | AlertTriangle | File taking long, low quality |
|
||||
| INFO | Blue ℹ️ | Info | Processing started, general info |
|
||||
| DEADLINE | Red 🕐 | Clock | Contract expiring soon |
|
||||
|
||||
## 🎛️ Notification Bar Features
|
||||
|
||||
### When Closed
|
||||
|
||||
- Shows bell icon
|
||||
- Displays badge with unread count
|
||||
- Pulses when unread notification arrives
|
||||
|
||||
### When Open
|
||||
|
||||
- Dropdown panel (w-96 max)
|
||||
- Shows up to 15 most recent notifications
|
||||
- Each notification shows:
|
||||
- Type-specific icon and color
|
||||
- Title and message
|
||||
- Time (e.g., "2m ago")
|
||||
- Contract link if available
|
||||
- Unread indicator (red dot)
|
||||
- Action buttons (✓ mark as read, 🗑️ delete)
|
||||
- "Mark all as read" button
|
||||
- Empty state if no notifications
|
||||
|
||||
### Auto-Refresh Behavior
|
||||
|
||||
- Refreshes every 30 seconds when dropdown is open
|
||||
- Checks for deadline notifications daily (24 hours)
|
||||
- Silent refresh (doesn't show loading if already open)
|
||||
|
||||
## 🔐 Security & Authorization
|
||||
|
||||
All operations include authentication and authorization:
|
||||
|
||||
1. **Clerk Authentication**: All actions verify user is logged in
|
||||
2. **User Verification**: Notifications belong to authenticated user
|
||||
3. **Contract Ownership**: Users only see their own contract notifications
|
||||
4. **Server-Side Enforcement**: All operations run on server (no client manipulation)
|
||||
5. **Database Constraints**: Foreign keys prevent orphaned records
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
### Modify Deadline Thresholds
|
||||
|
||||
Edit `lib/services/notification.service.ts`:
|
||||
|
||||
```typescript
|
||||
if (daysUntilExpiration === 7) {
|
||||
// Change 7 to any number
|
||||
shouldNotify = true;
|
||||
level = "URGENT";
|
||||
}
|
||||
```
|
||||
|
||||
### Change Default Expiration
|
||||
|
||||
Edit `lib/services/notification.service.ts`:
|
||||
|
||||
```typescript
|
||||
const expiresAt = input.expiresIn
|
||||
? new Date(Date.now() + input.expiresIn)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Change 30 to any days
|
||||
```
|
||||
|
||||
### Adjust Polling Interval
|
||||
|
||||
Edit `components/views/dashboard/notification-bar.tsx`:
|
||||
|
||||
```typescript
|
||||
const pollInterval = setInterval(fetchNotifications, 30000); // 30 seconds, change to any ms
|
||||
```
|
||||
|
||||
## 📱 Database Schema
|
||||
|
||||
### Notification Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE "Notification" (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL, -- Link to User
|
||||
contractId TEXT, -- Link to Contract (optional)
|
||||
type NotificationType, -- SUCCESS, WARNING, ERROR, INFO, DEADLINE
|
||||
title VARCHAR(255), -- e.g., "Contract Uploaded"
|
||||
message TEXT, -- e.g., "insurance.pdf uploaded successfully"
|
||||
icon VARCHAR(100), -- Lucide icon name for UI
|
||||
actionType VARCHAR(100), -- e.g., "UPLOAD_SUCCESS", "RENEWAL_CRITICAL"
|
||||
actionData JSONB, -- Additional metadata
|
||||
read BOOLEAN DEFAULT false, -- Read status for badge
|
||||
createdAt TIMESTAMP, -- When created
|
||||
expiresAt TIMESTAMP, -- When notification expires
|
||||
|
||||
FOREIGN KEY (userId) REFERENCES "User"(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (contractId) REFERENCES "Contract"(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Indexes for fast queries
|
||||
CREATE INDEX idx_userId ON "Notification"(userId);
|
||||
CREATE INDEX idx_contractId ON "Notification"(contractId);
|
||||
CREATE INDEX idx_type ON "Notification"(type);
|
||||
CREATE INDEX idx_read ON "Notification"(read);
|
||||
CREATE INDEX idx_createdAt ON "Notification"(createdAt DESC);
|
||||
```
|
||||
|
||||
## 🧪 Testing the System
|
||||
|
||||
### Manual Tests
|
||||
|
||||
1. **Upload Notification**
|
||||
- Go to /contacts
|
||||
- Upload a contract
|
||||
- Should see green toast: "📄 Contract Uploaded"
|
||||
- Check notification bar - dot should appear
|
||||
|
||||
2. **Analysis Notification**
|
||||
- Click "Analyze" on uploaded contract
|
||||
- Should see loading toast
|
||||
- After 5-10 seconds, see success or error toast
|
||||
- Check notification bar for detailed message
|
||||
|
||||
3. **Delete Notification**
|
||||
- Click delete on any contract
|
||||
- Confirm in modal
|
||||
- Should see toast: "🗑️ Contract Deleted"
|
||||
|
||||
4. **Deadline Notification**
|
||||
- Create a contract with endDate in 10 days
|
||||
- Go to dashboard
|
||||
- System automatically checks and creates notification
|
||||
- See 🟡 URGENT notification in bell icon
|
||||
|
||||
5. **Notification Bar Features**
|
||||
- Click bell icon to open dropdown
|
||||
- Click ✓ to mark individual as read
|
||||
- Click 🗑️ to delete notification
|
||||
- Click "Mark all as read" button
|
||||
- Should auto-refresh when open
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Migration Fails
|
||||
|
||||
```bash
|
||||
# Solution 1: Check database connection
|
||||
echo $DATABASE_URL
|
||||
|
||||
# Solution 2: Reset migrations (dev only)
|
||||
npx prisma migrate reset
|
||||
|
||||
# Solution 3: Manually deploy
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Notifications Not Showing
|
||||
|
||||
```bash
|
||||
# Check 1: Verify notifications table exists
|
||||
npx prisma db push
|
||||
|
||||
# Check 2: Check database connection in server
|
||||
# Open browser DevTools → Network tab
|
||||
# Look for failed API calls to notification endpoints
|
||||
|
||||
# Check 3: Verify Prisma client is generated
|
||||
npx prisma generate
|
||||
|
||||
# Check 4: Rebuild project
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Deadline Notifications Not Triggering
|
||||
|
||||
```
|
||||
Check 1: Contract has endDate (not null)
|
||||
Check 2: Contract status is "COMPLETED" (not UPLOADED/PROCESSING)
|
||||
Check 3: Date is calculated correctly (midnight UTC)
|
||||
Check 4: Manually trigger: await checkDeadlineNotifications()
|
||||
```
|
||||
|
||||
### TypeScript Errors After Update
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npx prisma generate
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 📚 Code Examples
|
||||
|
||||
### Create Custom Notification in Server Action
|
||||
|
||||
```typescript
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
const result = await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "Custom Title",
|
||||
message: "Custom message content",
|
||||
contractId: contract.id,
|
||||
actionType: "CUSTOM_ACTION",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
```
|
||||
|
||||
### Use Toast in Client Component
|
||||
|
||||
```typescript
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export function MyComponent() {
|
||||
const { notifySuccess, notifyError } = useNotifications();
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
await someOperation();
|
||||
notifySuccess("Success!", "Operation completed");
|
||||
} catch (error) {
|
||||
notifyError("Error!", error.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Check Notifications from Component
|
||||
|
||||
```typescript
|
||||
import { getNotifications } from "@/lib/actions/notification.action";
|
||||
|
||||
export async function NotificationPreview() {
|
||||
const result = await getNotifications(10);
|
||||
|
||||
if (result.success) {
|
||||
console.log(result.data); // Array of notifications
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🎓 Learning Resources
|
||||
|
||||
1. **Sonner Documentation**: https://sonner.emilkowal.ski/
|
||||
2. **Prisma Documentation**: https://www.prisma.io/docs/
|
||||
3. **Next.js Server Actions**: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
|
||||
4. **Shadcn/ui Components**: https://ui.shadcn.com/
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
|
||||
Potential features to add:
|
||||
|
||||
- [ ] Email notifications for deadline alerts
|
||||
- [ ] Push notifications (Web/Mobile)
|
||||
- [ ] Notification preferences (user can disable types)
|
||||
- [ ] Snooze feature (temporarily hide notifications)
|
||||
- [ ] Advanced filtering (by type, date, contract)
|
||||
- [ ] Bulk operations (mark all, delete all)
|
||||
- [ ] Export notification history (CSV)
|
||||
- [ ] Recurring reminders if ignored
|
||||
- [ ] Notification sounds/vibrations
|
||||
- [ ] Smart digest (combine similar notifications)
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check error console (browser DevTools)
|
||||
2. Review NOTIFICATION_SYSTEM_SETUP.md
|
||||
3. Check notification-bar.tsx for UI implementation
|
||||
4. See lib/services/notification.service.ts for core logic
|
||||
5. Review contracts-list.tsx for toast integration examples
|
||||
|
||||
## ✅ Checklist - Setup Verification
|
||||
|
||||
After setup, verify:
|
||||
|
||||
- [ ] Database migration completed successfully
|
||||
- [ ] Prisma client generated
|
||||
- [ ] No TypeScript errors in build
|
||||
- [ ] NotificationBar visible in dashboard sidebar
|
||||
- [ ] Upload creates notification toast
|
||||
- [ ] Analysis creates notification toast
|
||||
- [ ] Delete creates notification toast
|
||||
- [ ] Notification bar bell icon shows unread count
|
||||
- [ ] Notification dropdown opens/closes smoothly
|
||||
- [ ] Can mark notifications as read/delete
|
||||
- [ ] Deadline notifications appear for contracts expiring in 30/15/7 days
|
||||
- [ ] Auto-refresh works when dropdown is open
|
||||
|
||||
## 🎉 You're All Set!
|
||||
|
||||
The notification system is now fully integrated with:
|
||||
|
||||
- ✅ Sonner toast notifications for immediate feedback
|
||||
- ✅ Persistent database notifications for history
|
||||
- ✅ Beautiful notification bar UI with unread badge
|
||||
- ✅ Automatic deadline detection and alerts
|
||||
- ✅ Well-documented, production-ready code
|
||||
|
||||
Start uploading contracts and watch the notifications come to life!
|
||||
386
NOTIFICATION_SYSTEM_SETUP.md
Normal file
386
NOTIFICATION_SYSTEM_SETUP.md
Normal file
@@ -0,0 +1,386 @@
|
||||
# 🔔 Notification System Setup Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The notification system has been implemented to notify users about:
|
||||
|
||||
- ✅ **Action Notifications**: When users upload, analyze, or delete contracts
|
||||
- 🕐 **Deadline Notifications**: When contracts are expiring (30, 15, 7 days)
|
||||
- 📱 **Toast Notifications**: Immediate UI feedback for all actions
|
||||
- 🔔 **Notification Center**: Persistent notification history accessible from the dashboard
|
||||
|
||||
## Database Migration
|
||||
|
||||
### Step 1: Update Your Database Schema
|
||||
|
||||
Run the following Prisma command to create the necessary database tables:
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev --name add_notifications
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Create the `Notification` table
|
||||
2. Create the `NotificationType` enum
|
||||
3. Add the `notifications` relationship to the `User` model
|
||||
4. Add the `notifications` relationship to the `Contract` model
|
||||
|
||||
### Step 2: Database Schema Overview
|
||||
|
||||
The migration adds:
|
||||
|
||||
```sql
|
||||
-- Notification table with indexes
|
||||
CREATE TABLE "Notification" (
|
||||
id TEXT PRIMARY KEY,
|
||||
userId TEXT NOT NULL,
|
||||
contractId TEXT,
|
||||
type "NotificationType" NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
icon VARCHAR(100),
|
||||
actionType VARCHAR(100),
|
||||
actionData JSONB,
|
||||
read BOOLEAN DEFAULT false,
|
||||
createdAt TIMESTAMP DEFAULT now(),
|
||||
expiresAt TIMESTAMP,
|
||||
FOREIGN KEY (userId) REFERENCES "User"(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (contractId) REFERENCES "Contract"(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Notification Type Enum
|
||||
CREATE TYPE "NotificationType" AS ENUM (
|
||||
'SUCCESS',
|
||||
'WARNING',
|
||||
'ERROR',
|
||||
'INFO',
|
||||
'DEADLINE'
|
||||
);
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### 1. **Notification Service** (`lib/services/notification.service.ts`)
|
||||
|
||||
Core service handling all notification operations:
|
||||
|
||||
- Create notifications
|
||||
- Fetch unread/all notifications
|
||||
- Mark as read
|
||||
- Delete notifications
|
||||
- Check for upcoming deadlines
|
||||
- Cleanup expired notifications
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
- `create(input)` - Create new notification
|
||||
- `getUnread(userId, limit)` - Fetch unread notifications
|
||||
- `getUnreadCount(userId)` - Get badge count
|
||||
- `checkUpcomingDeadlines(userId)` - Scan contracts and create deadline notifications
|
||||
- `cleanupExpired()` - Remove expired notifications (run periodically)
|
||||
|
||||
### 2. **Notification Actions** (`lib/actions/notification.action.ts`)
|
||||
|
||||
Server actions for client-side notification management:
|
||||
|
||||
- `getNotifications()` - Fetch unread notifications
|
||||
- `getAllNotifications()` - Fetch notification history
|
||||
- `getUnreadNotificationCount()` - Get badge count
|
||||
- `markNotificationAsRead(id)` - Mark single as read
|
||||
- `markAllNotificationsAsRead()` - Mark all as read
|
||||
- `deleteNotification(id)` - Delete notification
|
||||
- `checkDeadlineNotifications()` - Check and create deadline notifications
|
||||
|
||||
### 3. **Notification Component** (`components/views/dashboard/notification-bar.tsx`)
|
||||
|
||||
Beautiful notification UI dropdown with:
|
||||
|
||||
- Bell icon with unread count badge
|
||||
- Notification list with type-specific icons and colors
|
||||
- Action buttons (mark as read, delete)
|
||||
- Time formatting (e.g., "2m ago")
|
||||
- Empty state
|
||||
- Auto-refresh every 30 seconds when open
|
||||
- Daily deadline check
|
||||
|
||||
### 4. **useNotifications Hook** (`hooks/useNotifications.ts`)
|
||||
|
||||
Custom React hook wrapping Sonner toast + persistent notifications:
|
||||
|
||||
- `notifySuccess()` - Green success toast + persistent notification
|
||||
- `notifyError()` - Red error toast + persistent notification
|
||||
- `notifyWarning()` - Yellow warning toast + persistent notification
|
||||
- `notifyInfo()` - Blue info toast + persistent notification
|
||||
- `notifyDeadline()` - Red deadline toast + persistent notification
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. **Contract Actions** (`lib/actions/contract.action.ts`)
|
||||
|
||||
Updated to create notifications on:
|
||||
|
||||
- ✅ **Upload**: "Contract uploaded successfully"
|
||||
- ✅ **Analysis**: "Contract analyzed successfully" or error message
|
||||
- ✅ **Delete**: "Contract deleted successfully"
|
||||
- ✅ **Ask Question**: Error notification if Q&A fails
|
||||
|
||||
### 2. **Dashboard** (`app/(dashboard)/dashboard/page.tsx`)
|
||||
|
||||
- Calls `checkDeadlineNotifications()` on page load
|
||||
- Checks for contracts expiring in 30, 15, 7 days
|
||||
- Creates notifications automatically
|
||||
|
||||
### 3. **Navigation** (`components/views/dashboard/navigation.tsx`)
|
||||
|
||||
- Added `NotificationBar` component to sidebar
|
||||
- Positioned next to theme toggle in account section
|
||||
- Accessible from all dashboard pages
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a Notification in Server Actions
|
||||
|
||||
```typescript
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
// In a server action
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "Contract Uploaded",
|
||||
message: 'Your file "insurance.pdf" is ready for analysis',
|
||||
contractId: contractId,
|
||||
actionType: "UPLOAD_SUCCESS",
|
||||
icon: "FileCheck",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
```
|
||||
|
||||
### Using Toast Notifications in Client Components
|
||||
|
||||
```typescript
|
||||
import { useNotifications } from "@/hooks/useNotifications";
|
||||
|
||||
export function MyComponent() {
|
||||
const { notifySuccess, notifyError, notifyDeadline } = useNotifications();
|
||||
|
||||
const handleUpload = async () => {
|
||||
try {
|
||||
await uploadFile();
|
||||
notifySuccess("Upload Complete", "Your file has been uploaded");
|
||||
} catch (error) {
|
||||
notifyError("Upload Failed", error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeadline = () => {
|
||||
notifyDeadline({
|
||||
title: "🔴 Contract Expiring Soon",
|
||||
message: "Insurance Auto from ACME expires in 7 days",
|
||||
contractId: "contract123",
|
||||
actionType: "RENEWAL_URGENT",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={handleUpload}>Upload</button>
|
||||
<button onClick={handleDeadline}>Test Deadline</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Notification Types
|
||||
|
||||
### SUCCESS (Green - ✅)
|
||||
|
||||
Used for:
|
||||
|
||||
- Contract uploaded
|
||||
- Analysis completed
|
||||
- Contract deleted
|
||||
- Settings saved
|
||||
|
||||
### ERROR (Red - ❌)
|
||||
|
||||
Used for:
|
||||
|
||||
- Upload failed
|
||||
- Analysis failed
|
||||
- Network errors
|
||||
- Invalid contract
|
||||
|
||||
### WARNING (Yellow - ⚠️)
|
||||
|
||||
Used for:
|
||||
|
||||
- File analysis slow
|
||||
- Low quality extraction
|
||||
- Missing permissions
|
||||
|
||||
### INFO (Blue - ℹ️)
|
||||
|
||||
Used for:
|
||||
|
||||
- Analysis started
|
||||
- Processing updates
|
||||
- General information
|
||||
|
||||
### DEADLINE (Red - 🕐)
|
||||
|
||||
Used for:
|
||||
|
||||
- Contract expiring in 30 days (CRITICAL 🔴)
|
||||
- Contract expiring in 15 days (WARNING 🟠)
|
||||
- Contract expiring in 7 days (URGENT 🟡)
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
### Daily Deadline Check
|
||||
|
||||
The system checks for upcoming deadlines:
|
||||
|
||||
- **When**: Daily at any time (triggered on dashboard load)
|
||||
- **What**: Scans all user contracts with endDate
|
||||
- **Actions**:
|
||||
- Creates CRITICAL notification for 30-day threshold
|
||||
- Creates WARNING notification for 15-day threshold
|
||||
- Creates URGENT notification for 7-day threshold
|
||||
- Avoids duplicate notifications (max 1 per threshold per day)
|
||||
|
||||
### Running Deadline Checks Manually
|
||||
|
||||
```typescript
|
||||
import { checkDeadlineNotifications } from "@/lib/actions/notification.action";
|
||||
|
||||
// Client-side
|
||||
const result = await checkDeadlineNotifications();
|
||||
console.log(`Created ${result.data.count} deadline notifications`);
|
||||
```
|
||||
|
||||
### Cleanup Task (Recommended)
|
||||
|
||||
For production, set up a cron job to clean expired notifications:
|
||||
|
||||
```typescript
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
// Example: Vercel Cron (serverless function every day at midnight)
|
||||
export async function GET(request: Request) {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const result = await NotificationService.cleanupExpired();
|
||||
return Response.json(result);
|
||||
}
|
||||
|
||||
// Route: api/cron/cleanup-notifications
|
||||
// Set Cron Job: 0 0 * * * (daily at midnight)
|
||||
```
|
||||
|
||||
## Configurable Parameters
|
||||
|
||||
### Default Notification Expiration
|
||||
|
||||
```typescript
|
||||
// In NotificationService.create()
|
||||
const expiresAt = input.expiresIn
|
||||
? new Date(Date.now() + input.expiresIn)
|
||||
: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days default
|
||||
```
|
||||
|
||||
### Deadline Thresholds
|
||||
|
||||
Located in `NotificationService.checkUpcomingDeadlines()`:
|
||||
|
||||
- 30 days: CRITICAL level
|
||||
- 15 days: WARNING level
|
||||
- 7 days: URGENT level
|
||||
|
||||
To modify, edit these lines in `notification.service.ts`:
|
||||
|
||||
```typescript
|
||||
if (daysUntilExpiration === 7) {
|
||||
/* ... */
|
||||
} else if (daysUntilExpiration === 15) {
|
||||
/* ... */
|
||||
} else if (daysUntilExpiration === 30) {
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
|
||||
### Notification Bar Polling
|
||||
|
||||
```typescript
|
||||
// In notification-bar.tsx
|
||||
const pollInterval = setInterval(fetchNotifications, 30000); // 30 seconds
|
||||
const dailyCheckInterval = setInterval(
|
||||
() => {
|
||||
checkDeadlineNotifications();
|
||||
},
|
||||
24 * 60 * 60 * 1000,
|
||||
); // 24 hours
|
||||
```
|
||||
|
||||
## Security & Authorization
|
||||
|
||||
All notification operations include authorization checks:
|
||||
|
||||
1. **User Verification**: Each action verifies the user owns the notification
|
||||
2. **Contract Ownership**: Deadline checks only process user's contracts
|
||||
3. **Input Validation**: Notification content isn't sanitized (all text)
|
||||
4. **Server-Side Enforcement**: All operations run server-side (no client-side manipulation)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migrations Don't Apply
|
||||
|
||||
```bash
|
||||
# Reset migrations (for development only)
|
||||
npx prisma migrate reset
|
||||
|
||||
# Or manually apply pending migrations
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
### Notifications Not Showing
|
||||
|
||||
1. Check database connection
|
||||
2. Verify user exists in User table
|
||||
3. Check browser console for errors
|
||||
4. Verify `checkDeadlineNotifications` was called
|
||||
|
||||
### Deadline Notifications Not Triggering
|
||||
|
||||
1. Ensure contract has `endDate` set
|
||||
2. Verify contract status is "COMPLETED"
|
||||
3. Check date calculation (today at midnight)
|
||||
4. Run manual check: `await checkDeadlineNotifications()`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
|
||||
1. **Email Notifications**: Send deadline alerts via email
|
||||
2. **Batch Operations**: Bulk mark/delete notifications
|
||||
3. **Notification Preferences**: Let users disable certain types
|
||||
4. **Snooze Feature**: Temporarily hide notifications
|
||||
5. **Advanced Filtering**: Filter by type, date range, contract
|
||||
6. **Export History**: Download notification log as CSV
|
||||
7. **Push Notifications**: Web/mobile push for critical alerts
|
||||
8. **Recurring Reminders**: Repeat deadline notifications if ignored
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
|
||||
1. Check the notification service comments for implementation details
|
||||
2. Review contracts-list.tsx for toast integration examples
|
||||
3. Check notification-bar.tsx for UI implementation
|
||||
4. See hooks/useNotifications.ts for hook usage
|
||||
208
PROJECT_ARCHITECTURE_AND_STACK.md
Normal file
208
PROJECT_ARCHITECTURE_AND_STACK.md
Normal file
@@ -0,0 +1,208 @@
|
||||
# Project Architecture & Technology Stack
|
||||
|
||||
**Project:** LexiChain (BFSI Contract Intelligence Platform)
|
||||
**Date:** March 8, 2026
|
||||
**Scope:** Rationale for choosing Next.js, current stack snapshot, and end-to-end communication architecture.
|
||||
|
||||
## 1) Why We Chose Next.js
|
||||
|
||||
We selected **Next.js** because it aligns with the exact technical and product needs of this project:
|
||||
|
||||
1. **Full-stack in one framework**
|
||||
The project combines UI, backend logic, and API endpoints in a single codebase (`app` routes, server actions, route handlers). This reduces context switching and speeds up delivery.
|
||||
|
||||
2. **App Router structure for product domains**
|
||||
Route groups like `(auth)` and `(dashboard)` map directly to business areas, making navigation and ownership clear.
|
||||
|
||||
3. **Server Actions for secure business operations**
|
||||
Operations such as saving contracts, analysis triggers, and fetching statistics are executed server-side (`"use server"`), which keeps privileged logic off the client.
|
||||
|
||||
4. **Built-in route handlers for integrations**
|
||||
Integrations such as Clerk webhooks and UploadThing endpoints are implemented natively in `app/api/*`, with no separate backend server required.
|
||||
|
||||
5. **Strong authentication integration**
|
||||
Clerk works naturally with Next.js server functions (`auth()`), middleware/proxy route protection, and auth UI pages.
|
||||
|
||||
6. **Performance and UX benefits**
|
||||
Next.js supports hybrid rendering and optimized routing while allowing rich client interactivity where needed (dashboard, charts, upload flows).
|
||||
|
||||
7. **Excellent TypeScript compatibility**
|
||||
The project uses strict TypeScript with shared models and service layers, improving reliability in a data-sensitive BFSI context.
|
||||
|
||||
---
|
||||
|
||||
## 2) Current Stack (As Implemented)
|
||||
|
||||
### Core Platform
|
||||
- **Next.js:** `16.1.6`
|
||||
- **React:** `19.2.3`
|
||||
- **TypeScript:** `5.x` (strict mode)
|
||||
- **Node/NPM scripts:** standard `dev`, `build`, `start`, `lint`
|
||||
|
||||
### UI & Frontend
|
||||
- **Tailwind CSS** + `tailwindcss-animate`
|
||||
- **shadcn/ui** (New York style) built on **Radix UI** primitives
|
||||
- **Lucide React** icons
|
||||
- **Motion** (`motion/react`) for animations
|
||||
- **Recharts** for analytics visualizations
|
||||
- **Sonner** for toast notifications
|
||||
- **next-themes** for dark/light mode
|
||||
|
||||
### Authentication & Access Control
|
||||
- **Clerk** (`@clerk/nextjs`, `@clerk/themes`)
|
||||
- Route protection through `proxy.ts` using Clerk middleware
|
||||
- Auth flows with Clerk `SignIn` and `SignUp` pages
|
||||
- Webhook verification using **Svix** for secure user lifecycle sync
|
||||
|
||||
### Data Layer
|
||||
- **PostgreSQL** via **Prisma ORM** (`@prisma/client`, `prisma`)
|
||||
- Main entities: `User` and `Contract`
|
||||
- Enum-based domain modeling (`ContractType`, `ContractStatus`)
|
||||
|
||||
### File Ingestion & Storage
|
||||
- **UploadThing** (`uploadthing`, `@uploadthing/react`)
|
||||
- Controlled file constraints and authenticated upload middleware
|
||||
- Upload completion callback integrated with contract persistence
|
||||
|
||||
### AI & Contract Intelligence
|
||||
- **Google Generative AI** SDK (`@google/generative-ai`)
|
||||
- Gemini model (`gemini-2.5-flash`) for:
|
||||
- contract extraction,
|
||||
- structured summary generation,
|
||||
- key-point generation,
|
||||
- contract Q&A.
|
||||
|
||||
### Validation / Forms / Utility
|
||||
- **react-hook-form**, **zod**, `@hookform/resolvers`
|
||||
- Utility stack: `clsx`, `class-variance-authority`, `tailwind-merge`, etc.
|
||||
|
||||
---
|
||||
|
||||
## 3) Architecture Overview
|
||||
|
||||
The app follows a **layered Next.js architecture** with clear responsibilities:
|
||||
|
||||
1. **Presentation Layer (Client Components / Views)**
|
||||
- Route UI in `app/*`
|
||||
- Reusable view modules in `components/views/*`
|
||||
- Design-system components in `components/ui/*`
|
||||
|
||||
2. **Application Layer (Server Actions)**
|
||||
- `lib/actions/*`
|
||||
- Entry point for authenticated server-side operations initiated by the UI
|
||||
|
||||
3. **Domain/Service Layer**
|
||||
- `lib/services/*`
|
||||
- Encapsulates business logic (contracts, AI analysis, stats, storage helpers)
|
||||
|
||||
4. **Persistence Layer**
|
||||
- `lib/db/prisma.ts` + `prisma/schema.prisma`
|
||||
- Database operations and schema modeling
|
||||
|
||||
5. **Integration Layer (External Systems)**
|
||||
- Route handlers in `app/api/*` (UploadThing, Clerk webhooks)
|
||||
- External providers: Clerk, UploadThing, Gemini API
|
||||
|
||||
### High-Level Component Map
|
||||
- **Public Marketing Experience:** `app/page.tsx` + `components/views/Home/*`
|
||||
- **Authenticated Workspace:** `app/(dashboard)/*` + dashboard navigation/layout
|
||||
- **Document Operations:** upload form, contract list, AI actions, analytics charts
|
||||
|
||||
---
|
||||
|
||||
## 4) How Each Part Communicates
|
||||
|
||||
### A. Authentication and Access
|
||||
1. User requests a protected page.
|
||||
2. `proxy.ts` (Clerk middleware) checks route rules and enforces authentication.
|
||||
3. Inside server layouts/actions, `auth()` verifies user identity again for operation-level security.
|
||||
4. Clerk webhook events (`app/api/webhooks/clerk/route.ts`) synchronize user records into PostgreSQL.
|
||||
|
||||
**Result:** route-level and operation-level protection, plus identity consistency between Clerk and local DB.
|
||||
|
||||
### B. Contract Upload and Persistence
|
||||
1. User uploads a file from `ContractUploadForm` (client).
|
||||
2. Upload is sent to UploadThing endpoint (`/api/uploadthing`).
|
||||
3. UploadThing middleware validates authenticated user.
|
||||
4. On completion, client calls server action `saveContract`.
|
||||
5. Server action delegates to `ContractService.create`.
|
||||
6. Service resolves local user via `clerkId` and inserts `Contract` record with status lifecycle.
|
||||
7. Paths are revalidated so UI refreshes with latest data.
|
||||
|
||||
**Result:** secure file ingestion + persistent contract metadata in one flow.
|
||||
|
||||
### C. Contract Analysis (AI Enrichment)
|
||||
1. User clicks “Analyze” in contracts list.
|
||||
2. Client triggers `analyzeContractAction` (server action).
|
||||
3. Action validates auth, fetches contract, sets status to `PROCESSING`.
|
||||
4. `AIService` downloads file URL and sends content to Gemini.
|
||||
5. AI JSON output is validated and normalized.
|
||||
6. `ContractService.updateWithAIResults` saves extracted fields (`summary`, `keyPoints`, type, dates, premium, etc.) and marks status `COMPLETED`.
|
||||
7. Failures set status to `FAILED`.
|
||||
|
||||
**Result:** contract moves from raw upload to structured intelligence record.
|
||||
|
||||
### D. Analytics and Dashboard Data
|
||||
1. Dashboard UI calls `getStatsAction`.
|
||||
2. Action validates user and calls `stats.service`.
|
||||
3. Service runs Prisma aggregations/groupings.
|
||||
4. Data is returned as dashboard KPIs + chart-ready payloads.
|
||||
5. Recharts renders trend/type/status analytics on the client.
|
||||
|
||||
**Result:** real-time operational visibility from server-computed metrics.
|
||||
|
||||
### E. Contract Q&A
|
||||
1. User asks question in contract context.
|
||||
2. Client calls `askContractQuestionAction`.
|
||||
3. Server action fetches contract + analysis context.
|
||||
4. `AIService.askAboutContract` sends prompt + metadata/extracted content to Gemini.
|
||||
5. Sanitized answer returns to chat UI.
|
||||
|
||||
**Result:** contextual AI assistant grounded in analyzed contract data.
|
||||
|
||||
---
|
||||
|
||||
## 5) Runtime Communication Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
U[User Browser] --> UI[Next.js App Router UI]
|
||||
|
||||
UI -->|Server Actions| SA[lib/actions]
|
||||
SA --> SVC[lib/services]
|
||||
SVC --> DB[(PostgreSQL via Prisma)]
|
||||
|
||||
UI -->|Upload| UT[UploadThing]
|
||||
UT -->|Upload Callback Data| SA
|
||||
|
||||
SVC -->|Analyze / Q&A| AI[Google Gemini API]
|
||||
|
||||
Clerk[Clerk Auth] -->|Session/Auth| UI
|
||||
Clerk -->|Webhooks via Svix| WH[app/api/webhooks/clerk]
|
||||
WH --> DB
|
||||
|
||||
Proxy[proxy.ts Clerk Middleware] --> UI
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Architecture Characteristics
|
||||
|
||||
### Strengths
|
||||
- **Single cohesive full-stack codebase** with clear file-system boundaries
|
||||
- **Fast feature delivery** using App Router + server actions
|
||||
- **Good security baseline** (middleware + server auth checks + webhook verification)
|
||||
- **Strong type safety** across UI, actions, services, and data models
|
||||
- **Integration-ready design** for AI/document workflows
|
||||
|
||||
### Current Maturity Notes
|
||||
- AI execution is implemented server-side and can be triggered from user workflow.
|
||||
- Existing comments already indicate a future path for queue-based background orchestration in production-scale scenarios.
|
||||
|
||||
---
|
||||
|
||||
## 7) Conclusion
|
||||
|
||||
Next.js is the right foundation for this BFSI platform because it enables secure, full-stack product development in one architecture: rich UI, authenticated server operations, API integrations, and data persistence.
|
||||
|
||||
The current stack is modern, production-oriented, and well-aligned with the project goals: **contract ingestion, AI enrichment, analytics visibility, and secure user-scoped access**.
|
||||
33
app/(auth)/sign-in/[[...sign-in]]/page.tsx
Normal file
33
app/(auth)/sign-in/[[...sign-in]]/page.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
// The [[...sign-in]] folder name is MAGIC!
|
||||
// It catches all Clerk's internal sign-in routes
|
||||
// (sign-in, sign-in/factor-one, sign-in/sso-callback, etc.)
|
||||
|
||||
import { SignIn } from "@clerk/nextjs";
|
||||
|
||||
export default function SignInPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
{/* Left side - branding (optional, add later) */}
|
||||
<div className="w-full max-w-md">
|
||||
<SignIn
|
||||
appearance={{
|
||||
elements: {
|
||||
// Customize Clerk's UI to match your design
|
||||
rootBox: "w-full",
|
||||
card: "bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-slate-700 rounded-2xl",
|
||||
headerTitle: "text-slate-900 dark:text-slate-100 font-bold",
|
||||
headerSubtitle: "text-slate-600 dark:text-slate-400",
|
||||
socialButtonsBlockButton:
|
||||
"border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700",
|
||||
formFieldInput:
|
||||
"border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 focus:ring-blue-500",
|
||||
formButtonPrimary:
|
||||
"bg-blue-700 hover:bg-blue-800 text-white font-semibold",
|
||||
footerActionLink: "text-blue-700 hover:text-blue-800 font-medium",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
27
app/(auth)/sign-up/[[...sign-up]]/page.tsx
Normal file
27
app/(auth)/sign-up/[[...sign-up]]/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { SignUp } from "@clerk/nextjs";
|
||||
|
||||
export default function SignUpPage() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
|
||||
<div className="w-full max-w-md">
|
||||
<SignUp
|
||||
appearance={{
|
||||
elements: {
|
||||
rootBox: "w-full",
|
||||
card: "bg-white dark:bg-slate-800 shadow-xl border border-slate-200 dark:border-slate-700 rounded-2xl",
|
||||
headerTitle: "text-slate-900 dark:text-slate-100 font-bold",
|
||||
headerSubtitle: "text-slate-600 dark:text-slate-400",
|
||||
socialButtonsBlockButton:
|
||||
"border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700",
|
||||
formFieldInput:
|
||||
"border-slate-300 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100",
|
||||
formButtonPrimary:
|
||||
"bg-blue-700 hover:bg-blue-800 text-white font-semibold",
|
||||
footerActionLink: "text-blue-700 hover:text-blue-800 font-medium",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
16
app/(dashboard)/contacts/layout.tsx
Normal file
16
app/(dashboard)/contacts/layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
export default async function ContactsLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
110
app/(dashboard)/contacts/page.tsx
Normal file
110
app/(dashboard)/contacts/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
"use client";
|
||||
|
||||
import { ContractUploadForm } from "@/components/views/dashboard/contract-upload-form";
|
||||
import { EmptyContractsState } from "@/components/views/dashboard/empty-contracts-state";
|
||||
import { ContractsList } from "@/components/views/dashboard/contracts-list";
|
||||
import { ContactsHeader } from "@/components/views/dashboard/contacts-header";
|
||||
import { useState, useEffect } from "react";
|
||||
import { getContracts } from "@/lib/actions/contract.action";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function ContactsPage() {
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||
const [showContracts, setShowContracts] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
// Check if there are any existing contracts on mount
|
||||
useEffect(() => {
|
||||
const checkContracts = async () => {
|
||||
try {
|
||||
const result = await getContracts();
|
||||
if (
|
||||
result.success &&
|
||||
Array.isArray(result.contracts) &&
|
||||
result.contracts.length > 0
|
||||
) {
|
||||
setShowContracts(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check contracts:", error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkContracts();
|
||||
}, []);
|
||||
|
||||
const handleUploadSuccess = () => {
|
||||
setRefreshTrigger((prev) => prev + 1);
|
||||
setShowContracts(true);
|
||||
};
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background text-foreground overflow-hidden">
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 left-1/4 w-96 h-96 bg-primary/20 rounded-full blur-3xl animate-blob"></div>
|
||||
<div className="absolute top-1/2 right-1/4 w-96 h-96 bg-accent/20 rounded-full blur-3xl animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute -bottom-8 right-1/3 w-96 h-96 bg-secondary/20 rounded-full blur-3xl animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
<main className="relative z-10 flex flex-col h-screen overflow-auto items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 inline-block p-4 bg-background dark:bg-card rounded-full border border-border/50">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-primary border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
<p className="text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<main className="flex flex-col min-h-screen">
|
||||
<ContactsHeader />
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 space-y-8">
|
||||
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Upload Contract
|
||||
</h2>
|
||||
<p className="mt-2 text-sm md:text-base text-muted-foreground">
|
||||
Add PDF contracts and let the AI pipeline extract summary,
|
||||
key points, and legal-business insights.
|
||||
</p>
|
||||
</div>
|
||||
<ContractUploadForm onUploadSuccess={handleUploadSuccess} />
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/60 p-6 md:p-8">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Your Contracts
|
||||
</h2>
|
||||
<p className="mt-2 text-sm md:text-base text-muted-foreground">
|
||||
Review contract lifecycle, trigger analysis, and ask AI
|
||||
questions per file.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showContracts ? (
|
||||
<ContractsList refreshTrigger={refreshTrigger} />
|
||||
) : (
|
||||
<EmptyContractsState />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
814
app/(dashboard)/dashboard/page.tsx
Normal file
814
app/(dashboard)/dashboard/page.tsx
Normal file
@@ -0,0 +1,814 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
BarChart3,
|
||||
Brain,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Database,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { getStatsAction } from "@/lib/actions/stats.action";
|
||||
import { checkDeadlineNotifications } from "@/lib/actions/notification.action";
|
||||
import {
|
||||
ContractStatusChart,
|
||||
ContractTypeChart,
|
||||
TrendChart,
|
||||
} from "@/components/views/dashboard/charts";
|
||||
|
||||
interface DashboardStats {
|
||||
totalContracts: number;
|
||||
analyzedContracts: number;
|
||||
processingContracts: number;
|
||||
uploadedContracts: number;
|
||||
failedContracts: number;
|
||||
analysisRate: number;
|
||||
}
|
||||
|
||||
interface ChartData {
|
||||
byType: Array<{ type: string; count: number }>;
|
||||
byStatus: Array<{ status: string; count: number }>;
|
||||
trends: Array<{ date: string; count: number }>;
|
||||
}
|
||||
|
||||
interface PremiumInfo {
|
||||
averagePremium: number;
|
||||
totalPremium: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface RecentContract {
|
||||
id: string;
|
||||
title: string | null;
|
||||
type: string | null;
|
||||
createdAt: string;
|
||||
premium: number | null;
|
||||
}
|
||||
|
||||
interface AILearningTelemetry {
|
||||
completedSamples: number;
|
||||
completedLast7Days: number;
|
||||
avgSummaryLength: number;
|
||||
avgExtractedTextLength: number;
|
||||
avgKeyPointsPerContract: number;
|
||||
learningScore: number;
|
||||
improvementHint: string;
|
||||
}
|
||||
|
||||
interface StatsActionResult {
|
||||
success: boolean;
|
||||
stats?: DashboardStats;
|
||||
chartData?: ChartData;
|
||||
premiumInfo?: PremiumInfo;
|
||||
aiLearningTelemetry?: AILearningTelemetry;
|
||||
recentContracts?: RecentContract[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("en-US");
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
const defaultStats: DashboardStats = {
|
||||
totalContracts: 0,
|
||||
analyzedContracts: 0,
|
||||
processingContracts: 0,
|
||||
uploadedContracts: 0,
|
||||
failedContracts: 0,
|
||||
analysisRate: 0,
|
||||
};
|
||||
|
||||
const formatLastUpdated = (date: Date | null): string => {
|
||||
if (!date) {
|
||||
return "Just now";
|
||||
}
|
||||
|
||||
const seconds = Math.max(1, Math.floor((Date.now() - date.getTime()) / 1000));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ago`;
|
||||
};
|
||||
|
||||
const clampPercent = (value: number): number =>
|
||||
Math.max(0, Math.min(100, value));
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>(defaultStats);
|
||||
const [chartData, setChartData] = useState<ChartData | null>(null);
|
||||
const [premiumInfo, setPremiumInfo] = useState<PremiumInfo>({
|
||||
averagePremium: 0,
|
||||
totalPremium: 0,
|
||||
count: 0,
|
||||
});
|
||||
const [recentContracts, setRecentContracts] = useState<RecentContract[]>([]);
|
||||
const [aiLearningTelemetry, setAiLearningTelemetry] =
|
||||
useState<AILearningTelemetry>({
|
||||
completedSamples: 0,
|
||||
completedLast7Days: 0,
|
||||
avgSummaryLength: 0,
|
||||
avgExtractedTextLength: 0,
|
||||
avgKeyPointsPerContract: 0,
|
||||
learningScore: 0,
|
||||
improvementHint: "Analyze contracts to build your AI quality profile.",
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
const loadStats = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const isSilentRefresh = options?.silent ?? false;
|
||||
|
||||
if (isSilentRefresh) {
|
||||
setIsRefreshing(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = (await getStatsAction()) as StatsActionResult;
|
||||
|
||||
if (!result.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.stats) setStats(result.stats);
|
||||
if (result.chartData) setChartData(result.chartData);
|
||||
if (result.premiumInfo) setPremiumInfo(result.premiumInfo);
|
||||
if (result.aiLearningTelemetry) {
|
||||
setAiLearningTelemetry(result.aiLearningTelemetry);
|
||||
}
|
||||
if (result.recentContracts) setRecentContracts(result.recentContracts);
|
||||
|
||||
setLastUpdated(new Date());
|
||||
} finally {
|
||||
if (isSilentRefresh) {
|
||||
setIsRefreshing(false);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats();
|
||||
// Check for upcoming contract deadlines and create notifications
|
||||
void checkDeadlineNotifications();
|
||||
}, [loadStats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (stats.processingContracts === 0 && stats.uploadedContracts === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
void loadStats({ silent: true });
|
||||
}, 10000);
|
||||
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [loadStats, stats.processingContracts, stats.uploadedContracts]);
|
||||
|
||||
const hasChartData = useMemo(() => {
|
||||
if (!chartData) return false;
|
||||
|
||||
const trendsCount = chartData.trends.reduce(
|
||||
(total, entry) => total + entry.count,
|
||||
0,
|
||||
);
|
||||
const byStatusCount = chartData.byStatus.reduce(
|
||||
(total, entry) => total + entry.count,
|
||||
0,
|
||||
);
|
||||
const byTypeCount = chartData.byType.reduce(
|
||||
(total, entry) => total + entry.count,
|
||||
0,
|
||||
);
|
||||
|
||||
return trendsCount + byStatusCount + byTypeCount > 0;
|
||||
}, [chartData]);
|
||||
|
||||
const pendingContracts = stats.processingContracts + stats.uploadedContracts;
|
||||
|
||||
const analyzedPercent =
|
||||
stats.totalContracts > 0
|
||||
? clampPercent((stats.analyzedContracts / stats.totalContracts) * 100)
|
||||
: 0;
|
||||
|
||||
const pendingPercent =
|
||||
stats.totalContracts > 0
|
||||
? clampPercent((pendingContracts / stats.totalContracts) * 100)
|
||||
: 0;
|
||||
|
||||
const failedPercent =
|
||||
stats.totalContracts > 0
|
||||
? clampPercent((stats.failedContracts / stats.totalContracts) * 100)
|
||||
: 0;
|
||||
|
||||
const statusRows = [
|
||||
{
|
||||
label: "Uploaded",
|
||||
value: stats.uploadedContracts,
|
||||
colorClass: "bg-amber-500",
|
||||
},
|
||||
{
|
||||
label: "Processing",
|
||||
value: stats.processingContracts,
|
||||
colorClass: "bg-primary",
|
||||
},
|
||||
{
|
||||
label: "Analyzed",
|
||||
value: stats.analyzedContracts,
|
||||
colorClass: "bg-emerald-500",
|
||||
},
|
||||
{
|
||||
label: "Failed",
|
||||
value: stats.failedContracts,
|
||||
colorClass: "bg-destructive",
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="mx-auto max-w-7xl px-6 py-16">
|
||||
<Card className="rounded-3xl border-border/60 p-10">
|
||||
<div className="flex items-center gap-3 text-muted-foreground">
|
||||
<RefreshCw className="h-5 w-5 animate-spin" />
|
||||
Building your analytics workspace...
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="relative overflow-hidden border-b border-border/50">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.22),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.16),transparent_40%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--background)/0.95))]" />
|
||||
<svg
|
||||
viewBox="0 0 640 320"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute right-[-140px] top-[-50px] h-[340px] w-[540px] opacity-50"
|
||||
>
|
||||
<path
|
||||
d="M12 290C96 240 128 112 228 110C300 108 336 206 412 206C492 206 524 138 628 132"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 250C86 200 150 74 238 74C322 74 350 170 430 170C502 170 560 108 636 104"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="6 8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="228" cy="110" r="8" fill="hsl(var(--primary))" />
|
||||
<circle cx="412" cy="206" r="7" fill="hsl(var(--secondary))" />
|
||||
<circle cx="430" cy="170" r="7" fill="hsl(var(--accent))" />
|
||||
</svg>
|
||||
<div className="relative mx-auto max-w-7xl px-6 py-12">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="grid gap-8 xl:grid-cols-[1.45fr,0.95fr] xl:items-end"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Performance Overview
|
||||
</p>
|
||||
<h1 className="text-4xl font-semibold tracking-tight md:text-5xl">
|
||||
Financial Contracts Analytics
|
||||
</h1>
|
||||
<p className="max-w-2xl text-muted-foreground md:text-base">
|
||||
A reliable command center for uploaded documents, AI analysis
|
||||
throughput, and portfolio quality across your BFSI workflow.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-3 py-1 text-muted-foreground">
|
||||
<Activity className="h-3.5 w-3.5 text-primary" />
|
||||
Live metrics
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-border/70 bg-muted/50 px-3 py-1 text-muted-foreground">
|
||||
<Clock3 className="h-3.5 w-3.5 text-secondary" />
|
||||
{isRefreshing
|
||||
? "Syncing..."
|
||||
: `Updated ${formatLastUpdated(lastUpdated)}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="rounded-2xl border-border/60 bg-card/80 p-5 backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Pipeline Snapshot
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold">
|
||||
{numberFormatter.format(stats.totalContracts)} files
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/10 p-2.5">
|
||||
<Database className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-border/60 bg-muted/25 p-3">
|
||||
<svg
|
||||
viewBox="0 0 260 90"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="h-[92px] w-full"
|
||||
>
|
||||
<path
|
||||
d="M4 72H256"
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<rect
|
||||
x="24"
|
||||
y="32"
|
||||
width="28"
|
||||
height="40"
|
||||
rx="8"
|
||||
fill="hsl(var(--primary) / 0.85)"
|
||||
/>
|
||||
<rect
|
||||
x="76"
|
||||
y="22"
|
||||
width="28"
|
||||
height="50"
|
||||
rx="8"
|
||||
fill="hsl(var(--secondary) / 0.8)"
|
||||
/>
|
||||
<rect
|
||||
x="128"
|
||||
y="14"
|
||||
width="28"
|
||||
height="58"
|
||||
rx="8"
|
||||
fill="hsl(var(--accent) / 0.78)"
|
||||
/>
|
||||
<rect
|
||||
x="180"
|
||||
y="44"
|
||||
width="28"
|
||||
height="28"
|
||||
rx="8"
|
||||
fill="hsl(var(--destructive) / 0.8)"
|
||||
/>
|
||||
<path
|
||||
d="M24 30C64 8 98 10 142 18C176 24 214 34 240 22"
|
||||
stroke="hsl(var(--foreground) / 0.35)"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-xs">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<p className="text-muted-foreground">Analyzed</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">
|
||||
{numberFormatter.format(stats.analyzedContracts)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2">
|
||||
<p className="text-muted-foreground">Pending</p>
|
||||
<p className="mt-1 text-sm font-semibold text-foreground">
|
||||
{numberFormatter.format(pendingContracts)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void loadStats()}
|
||||
className="w-full rounded-xl"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button asChild className="w-full rounded-xl">
|
||||
<Link href="/contacts">
|
||||
Manage
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-7xl px-6 py-10">
|
||||
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4">
|
||||
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Total Files</p>
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<p className="mt-2 text-4xl font-semibold">
|
||||
{numberFormatter.format(stats.totalContracts)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Uploaded into your workspace
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Analyzed</p>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
</div>
|
||||
<p className="mt-2 text-4xl font-semibold">
|
||||
{numberFormatter.format(stats.analyzedContracts)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Completed by AI pipeline
|
||||
</p>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-muted/70">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-emerald-500"
|
||||
style={{ width: `${analyzedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Pending Queue</p>
|
||||
<Clock3 className="h-4 w-4 text-amber-500" />
|
||||
</div>
|
||||
<p className="mt-2 text-4xl font-semibold">
|
||||
{numberFormatter.format(pendingContracts)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Uploaded and processing files
|
||||
</p>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-muted/70">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-amber-500"
|
||||
style={{ width: `${pendingPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="rounded-2xl border-border/60 bg-card/70 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">Failed</p>
|
||||
<AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
</div>
|
||||
<p className="mt-2 text-4xl font-semibold">
|
||||
{numberFormatter.format(stats.failedContracts)}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Items needing re-analysis
|
||||
</p>
|
||||
<div className="mt-3 h-1.5 w-full rounded-full bg-muted/70">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-destructive"
|
||||
style={{ width: `${failedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="mt-5 rounded-2xl border-border/60 p-5">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.2fr,0.8fr]">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">Pipeline Pulse</h2>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{statusRows.map((row) => {
|
||||
const rowPercent =
|
||||
stats.totalContracts > 0
|
||||
? clampPercent((row.value / stats.totalContracts) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.label}
|
||||
className="rounded-xl border border-border/50 bg-muted/25 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between text-xs">
|
||||
<p className="text-muted-foreground">{row.label}</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{numberFormatter.format(row.value)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted/70">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${row.colorClass}`}
|
||||
style={{ width: `${rowPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Success Rate</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{stats.analysisRate}%
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Completed vs total files
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Avg Premium</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{currencyFormatter.format(premiumInfo.averagePremium)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Across {numberFormatter.format(premiumInfo.count)} analyzed
|
||||
files
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Total Premium</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{currencyFormatter.format(premiumInfo.totalPremium)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Portfolio value captured by AI
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-5 rounded-2xl border-border/60 p-5">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-semibold">AI Learning Telemetry</h2>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-primary/20 bg-primary/10 px-2.5 py-1 text-xs text-primary">
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
Score {aiLearningTelemetry.learningScore}/100
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Completed Samples</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{numberFormatter.format(aiLearningTelemetry.completedSamples)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{numberFormatter.format(aiLearningTelemetry.completedLast7Days)}{" "}
|
||||
in last 7 days
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg Summary Length
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{numberFormatter.format(aiLearningTelemetry.avgSummaryLength)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">characters</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Avg Extracted Text
|
||||
</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{numberFormatter.format(
|
||||
aiLearningTelemetry.avgExtractedTextLength,
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">characters</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
|
||||
<p className="text-xs text-muted-foreground">Avg Key Points</p>
|
||||
<p className="mt-1 text-2xl font-semibold text-foreground">
|
||||
{aiLearningTelemetry.avgKeyPointsPerContract.toFixed(1)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
items per analysis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-xl border border-border/50 bg-muted/20 p-3">
|
||||
<div className="mb-2 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>Learning quality index</span>
|
||||
<span>{aiLearningTelemetry.learningScore}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-gradient-to-r from-primary to-accent"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, aiLearningTelemetry.learningScore))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{aiLearningTelemetry.improvementHint}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{hasChartData ? (
|
||||
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-12">
|
||||
{chartData && chartData.trends.length > 0 && (
|
||||
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-8">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-medium">
|
||||
Upload Trend (30 days)
|
||||
</h2>
|
||||
</div>
|
||||
<div className="h-[320px]">
|
||||
<TrendChart data={chartData.trends} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{chartData && chartData.byStatus.length > 0 && (
|
||||
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<CheckCircle2 className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-medium">Processing Status</h2>
|
||||
</div>
|
||||
<div className="h-[320px]">
|
||||
<ContractStatusChart
|
||||
data={chartData.byStatus.map((s) => ({
|
||||
...s,
|
||||
name: s.status,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{chartData && chartData.byType.length > 0 && (
|
||||
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-7">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-medium">
|
||||
Contract Type Distribution
|
||||
</h2>
|
||||
</div>
|
||||
<div className="h-[300px]">
|
||||
<ContractTypeChart data={chartData.byType} />
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-5">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<h2 className="text-sm font-medium">Recent Analyses</h2>
|
||||
</div>
|
||||
|
||||
{recentContracts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{recentContracts.map((contract) => (
|
||||
<div
|
||||
key={contract.id}
|
||||
className="rounded-xl border border-border/50 bg-muted/25 p-3"
|
||||
>
|
||||
<p className="text-sm font-medium text-foreground line-clamp-1">
|
||||
{contract.title || "Untitled contract"}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{contract.type || "Unknown type"}</span>
|
||||
<span>
|
||||
{new Date(contract.createdAt).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium text-foreground">
|
||||
Premium:{" "}
|
||||
{contract.premium !== null
|
||||
? currencyFormatter.format(contract.premium)
|
||||
: "Not detected"}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-[300px] items-center justify-center rounded-xl border border-dashed border-border/70 bg-muted/20 text-center">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
No recent analyses yet
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Analyze a contract to populate this activity feed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="mt-6 rounded-2xl border-border/60 p-8">
|
||||
<div className="mx-auto flex max-w-2xl flex-col items-center text-center">
|
||||
<svg
|
||||
width="220"
|
||||
height="120"
|
||||
viewBox="0 0 220 120"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="mb-5 opacity-80"
|
||||
>
|
||||
<rect
|
||||
x="14"
|
||||
y="26"
|
||||
width="192"
|
||||
height="78"
|
||||
rx="14"
|
||||
stroke="currentColor"
|
||||
className="text-muted-foreground"
|
||||
/>
|
||||
<path
|
||||
d="M30 82L62 60L88 74L124 44L162 58L192 36"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
className="text-primary"
|
||||
/>
|
||||
<circle
|
||||
cx="62"
|
||||
cy="60"
|
||||
r="5"
|
||||
fill="currentColor"
|
||||
className="text-primary"
|
||||
/>
|
||||
<circle
|
||||
cx="124"
|
||||
cy="44"
|
||||
r="5"
|
||||
fill="currentColor"
|
||||
className="text-primary"
|
||||
/>
|
||||
<circle
|
||||
cx="192"
|
||||
cy="36"
|
||||
r="5"
|
||||
fill="currentColor"
|
||||
className="text-secondary"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="text-xl font-semibold">
|
||||
Your analytics will appear here
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Upload and analyze contracts to unlock trend and distribution
|
||||
charts.
|
||||
</p>
|
||||
<Button asChild className="mt-5 rounded-xl">
|
||||
<Link href="/contacts">
|
||||
Upload first contract
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
app/(dashboard)/layout.tsx
Normal file
22
app/(dashboard)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DashboardNavigation } from "@/components/views/dashboard/navigation";
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardNavigation />
|
||||
<div className="ml-72">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
8
app/api/uploadthing/core.ts
Normal file
8
app/api/uploadthing/core.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// src/app/api/uploadthing/core.ts
|
||||
|
||||
import { createRouteHandler } from "uploadthing/next";
|
||||
import { ourFileRouter } from "@/lib/upload";
|
||||
|
||||
export const { GET, POST } = createRouteHandler({
|
||||
router: ourFileRouter,
|
||||
});
|
||||
3
app/api/uploadthing/route.ts
Normal file
3
app/api/uploadthing/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// src/app/api/uploadthing/route.ts
|
||||
|
||||
export { GET, POST } from "./core";
|
||||
164
app/api/webhooks/clerk/route.ts
Normal file
164
app/api/webhooks/clerk/route.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
// src/app/api/webhooks/clerk/route.ts
|
||||
|
||||
import { Webhook } from "svix";
|
||||
import { headers } from "next/headers";
|
||||
import { WebhookEvent } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// STEP 1: Get the webhook secret from environment
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET;
|
||||
|
||||
if (!WEBHOOK_SECRET) {
|
||||
console.error("❌ Missing CLERK_WEBHOOK_SECRET in environment variables");
|
||||
return new Response("Server configuration error", { status: 500 });
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// STEP 2: Get headers needed for verification
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
const headerPayload = await headers();
|
||||
const svix_id = headerPayload.get("svix-id");
|
||||
const svix_timestamp = headerPayload.get("svix-timestamp");
|
||||
const svix_signature = headerPayload.get("svix-signature");
|
||||
|
||||
// If there are no headers, error out
|
||||
if (!svix_id || !svix_timestamp || !svix_signature) {
|
||||
console.error("❌ Missing svix headers");
|
||||
return new Response("Missing svix headers", { status: 400 });
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// STEP 3: Get the request body
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
const payload = await req.json();
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// STEP 4: Verify the webhook signature
|
||||
// This ensures the webhook is actually from Clerk
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
const wh = new Webhook(WEBHOOK_SECRET);
|
||||
let evt: WebhookEvent;
|
||||
|
||||
try {
|
||||
evt = wh.verify(body, {
|
||||
"svix-id": svix_id,
|
||||
"svix-timestamp": svix_timestamp,
|
||||
"svix-signature": svix_signature,
|
||||
}) as WebhookEvent;
|
||||
} catch (err) {
|
||||
console.error("❌ Webhook verification failed:", err);
|
||||
return new Response("Invalid signature", { status: 400 });
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
// STEP 5: Handle different webhook events
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
const eventType = evt.type;
|
||||
|
||||
console.log(`📥 Webhook received: ${eventType}`);
|
||||
|
||||
switch (eventType) {
|
||||
// ═══════════════════════════════════════════════════
|
||||
// USER CREATED
|
||||
// ═══════════════════════════════════════════════════
|
||||
case "user.created": {
|
||||
const { id, email_addresses, first_name, last_name, image_url } =
|
||||
evt.data;
|
||||
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { clerkId: id },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log(`⚠️ User already exists: ${id}`);
|
||||
return new Response("User already exists", { status: 200 });
|
||||
}
|
||||
|
||||
// Create user in database
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
clerkId: id,
|
||||
email: email_addresses[0]?.email_address ?? "",
|
||||
firstName: first_name ?? null,
|
||||
lastName: last_name ?? null,
|
||||
imageUrl: image_url ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ User created: ${user.email} (${user.id})`);
|
||||
|
||||
return new Response("User created successfully", { status: 201 });
|
||||
} catch (error) {
|
||||
console.error("❌ Error creating user:", error);
|
||||
return new Response("Error creating user", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// USER UPDATED
|
||||
// ═══════════════════════════════════════════════════
|
||||
case "user.updated": {
|
||||
const { id, email_addresses, first_name, last_name, image_url } =
|
||||
evt.data;
|
||||
|
||||
try {
|
||||
const user = await prisma.user.update({
|
||||
where: { clerkId: id },
|
||||
data: {
|
||||
email: email_addresses[0]?.email_address ?? "",
|
||||
firstName: first_name ?? null,
|
||||
lastName: last_name ?? null,
|
||||
imageUrl: image_url ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ User updated: ${user.email} (${user.id})`);
|
||||
|
||||
return new Response("User updated successfully", { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("❌ Error updating user:", error);
|
||||
return new Response("Error updating user", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// USER DELETED
|
||||
// ═══════════════════════════════════════════════════
|
||||
case "user.deleted": {
|
||||
const { id } = evt.data;
|
||||
|
||||
if (!id) {
|
||||
console.error("❌ No user ID provided in deletion event");
|
||||
return new Response("No user ID provided", { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Delete user (CASCADE will delete all related contracts)
|
||||
await prisma.user.delete({
|
||||
where: { clerkId: id },
|
||||
});
|
||||
|
||||
console.log(`✅ User deleted: ${id}`);
|
||||
|
||||
return new Response("User deleted successfully", { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("❌ Error deleting user:", error);
|
||||
return new Response("Error deleting user", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════
|
||||
// OTHER EVENTS (ignore)
|
||||
// ═══════════════════════════════════════════════════
|
||||
default: {
|
||||
console.log(`ℹ️ Unhandled webhook event: ${eventType}`);
|
||||
return new Response("Event type not handled", { status: 200 });
|
||||
}
|
||||
}
|
||||
}
|
||||
30
app/clerk-provider.tsx
Normal file
30
app/clerk-provider.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { ClerkProvider } from "@clerk/nextjs";
|
||||
import { dark } from "@clerk/themes";
|
||||
import { useTheme } from "next-themes";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function ClerkThemeProvider({ children }: { children: ReactNode }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDark = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<ClerkProvider
|
||||
appearance={{
|
||||
baseTheme: isDark ? dark : undefined,
|
||||
variables: {
|
||||
colorPrimary: isDark ? "#60A5FA" : "#2563EB",
|
||||
colorBackground: isDark ? "#0F172A" : "#FFFFFF",
|
||||
colorInputBackground: isDark ? "#111827" : "#FFFFFF",
|
||||
colorInputText: isDark ? "#E2E8F0" : "#0F172A",
|
||||
colorText: isDark ? "#E2E8F0" : "#0F172A",
|
||||
colorTextSecondary: isDark ? "#94A3B8" : "#475569",
|
||||
borderRadius: "0.75rem",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ClerkProvider>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,38 @@
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
|
||||
/* Smooth scrolling */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Better font rendering */
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
/* Selection styles */
|
||||
::selection {
|
||||
@apply bg-primary/20;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
:focus-visible {
|
||||
@apply outline-none ring-2 ring-primary ring-offset-2;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
||||
@@ -1,24 +1,89 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Poppins, Geist_Mono } from "next/font/google";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./provider";
|
||||
|
||||
const poppins = Poppins({
|
||||
// Modern sans-serif font for body text
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800", "900"],
|
||||
variable: "--font-poppins",
|
||||
variable: "--font-inter",
|
||||
display: "swap",
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
// Monospace font for code and numbers
|
||||
const jetbrainsMono = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-geist-mono",
|
||||
variable: "--font-mono",
|
||||
display: "swap",
|
||||
weight: ["400", "500", "600", "700"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: {
|
||||
default: "LexiChain - AI-Powered Contract Management",
|
||||
template: "%s | LexiChain",
|
||||
},
|
||||
description:
|
||||
"Intelligent BFSI contract management platform with AI-powered analysis. Manage your insurance, loan, and financial contracts with blockchain-verified security.",
|
||||
keywords: [
|
||||
"contract management",
|
||||
"insurance",
|
||||
"BFSI",
|
||||
"AI contract analysis",
|
||||
"document management",
|
||||
"blockchain",
|
||||
"smart contracts",
|
||||
],
|
||||
authors: [{ name: "Your Name" }],
|
||||
creator: "Your Name",
|
||||
publisher: "LexiChain",
|
||||
metadataBase: new URL("https://lexichain.com"), // Replace with your domain
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "fr_FR",
|
||||
url: "https://lexichain.com",
|
||||
title: "LexiChain - AI-Powered Contract Management",
|
||||
description:
|
||||
"Intelligent contract management platform with AI analysis and blockchain verification.",
|
||||
siteName: "LexiChain",
|
||||
images: [
|
||||
{
|
||||
url: "/og-image.png", // Create this image (1200x630px)
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "LexiChain Platform",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "LexiChain - AI-Powered Contract Management",
|
||||
description:
|
||||
"Intelligent contract management platform with AI analysis and blockchain verification.",
|
||||
images: ["/og-image.png"],
|
||||
creator: "@lexichain", // Replace with your Twitter handle
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
"max-video-preview": -1,
|
||||
"max-image-preview": "large",
|
||||
"max-snippet": -1,
|
||||
},
|
||||
},
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/favicon.ico" },
|
||||
{ url: "/icon-192.png", sizes: "192x192", type: "image/png" },
|
||||
{ url: "/icon-512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
apple: [{ url: "/apple-icon.png", sizes: "180x180", type: "image/png" }],
|
||||
},
|
||||
manifest: "/manifest.json",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -27,9 +92,18 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<head>
|
||||
{/* Preconnect to external domains for performance */}
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link
|
||||
rel="preconnect"
|
||||
href="https://fonts.gstatic.com"
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
className={`${poppins.variable} ${geistMono.variable} min-h-screen bg-background text-foreground antialiased`}
|
||||
className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}
|
||||
>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// src/components/providers.tsx
|
||||
"use client";
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { ReactNode } from "react";
|
||||
import { ClerkThemeProvider } from "./clerk-provider";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -10,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
<ClerkThemeProvider>{children}</ClerkThemeProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
BIN
architecture.png
Normal file
BIN
architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
@@ -1,36 +1,36 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
import { GripVertical } from "lucide-react";
|
||||
import * as ResizablePrimitive from "react-resizable-panels";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
||||
<ResizablePrimitive.PanelGroup
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||
<ResizablePrimitive.Group
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
const ResizablePanel = ResizablePrimitive.Panel;
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
||||
withHandle?: boolean
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean;
|
||||
}) => (
|
||||
<ResizablePrimitive.PanelResizeHandle
|
||||
<ResizablePrimitive.Separator
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.PanelResizeHandle>
|
||||
)
|
||||
</ResizablePrimitive.Separator>
|
||||
);
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||
|
||||
@@ -8,13 +8,15 @@ import {
|
||||
Lock,
|
||||
Check,
|
||||
Zap,
|
||||
Link,
|
||||
Link2,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { SignedIn, SignedOut } from "@clerk/nextjs";
|
||||
|
||||
// Ripple Effect Component
|
||||
function BackgroundRipple() {
|
||||
@@ -407,7 +409,7 @@ export function Hero() {
|
||||
{ icon: Lock, label: "Bank-Level Security" },
|
||||
{ icon: Check, label: "GDPR Certified" },
|
||||
{ icon: Zap, label: "Real-Time AI" },
|
||||
{ icon: Link, label: "Blockchain Verified" },
|
||||
{ icon: Link2, label: "Blockchain Verified" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -544,8 +546,10 @@ export function Hero() {
|
||||
}`}
|
||||
>
|
||||
{/* Primary CTA */}
|
||||
<Button
|
||||
className="
|
||||
<SignedOut>
|
||||
<Button
|
||||
asChild
|
||||
className="
|
||||
group relative px-12 py-5
|
||||
text-lg md:text-xl font-semibold
|
||||
text-white rounded-2xl
|
||||
@@ -560,10 +564,11 @@ export function Hero() {
|
||||
hover:shadow-xl hover:shadow-blue-500/40
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
{/* Glow background layer */}
|
||||
<div
|
||||
className="
|
||||
>
|
||||
<Link href="/sign-in">
|
||||
{/* Glow background layer */}
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-opacity duration-500
|
||||
@@ -573,31 +578,89 @@ export function Hero() {
|
||||
to-teal-400/20
|
||||
blur-xl
|
||||
"
|
||||
/>
|
||||
/>
|
||||
|
||||
{/* Animated gradient shift layer */}
|
||||
<div
|
||||
className="
|
||||
{/* Animated gradient shift layer */}
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
bg-[length:200%_200%]
|
||||
animate-gradient-shift
|
||||
opacity-70
|
||||
mix-blend-overlay
|
||||
"
|
||||
/>
|
||||
/>
|
||||
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
Get Started
|
||||
<Rocket
|
||||
className="
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
Get Started
|
||||
<Rocket
|
||||
className="
|
||||
w-6 h-6
|
||||
transition-transform duration-300
|
||||
group-hover:translate-x-1
|
||||
group-hover:-translate-y-1
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</SignedOut>
|
||||
|
||||
<SignedIn>
|
||||
<Button
|
||||
asChild
|
||||
className="
|
||||
group relative px-12 py-5
|
||||
text-lg md:text-xl font-semibold
|
||||
text-white rounded-2xl
|
||||
overflow-hidden
|
||||
bg-gradient-to-r
|
||||
from-[hsl(var(--primary))]
|
||||
via-[hsl(var(--accent))]
|
||||
to-[hsl(var(--secondary))]
|
||||
shadow-lg shadow-blue-500/20
|
||||
transition-all duration-300 ease-out
|
||||
hover:scale-[1.04]
|
||||
hover:shadow-xl hover:shadow-blue-500/40
|
||||
active:scale-[0.98]
|
||||
"
|
||||
>
|
||||
<Link href="/dashboard">
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
opacity-0 group-hover:opacity-100
|
||||
transition-opacity duration-500
|
||||
bg-gradient-to-r
|
||||
from-blue-400/20
|
||||
via-purple-400/20
|
||||
to-teal-400/20
|
||||
blur-xl
|
||||
"
|
||||
/>
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
bg-[length:200%_200%]
|
||||
animate-gradient-shift
|
||||
opacity-70
|
||||
mix-blend-overlay
|
||||
"
|
||||
/>
|
||||
<span className="relative z-10 flex items-center gap-3">
|
||||
Go To Dashboard
|
||||
<Rocket
|
||||
className="
|
||||
w-6 h-6
|
||||
transition-transform duration-300
|
||||
group-hover:translate-x-1
|
||||
group-hover:-translate-y-1
|
||||
"
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</SignedIn>
|
||||
</div>
|
||||
|
||||
{/* Trust Indicators */}
|
||||
|
||||
@@ -440,7 +440,7 @@ export function HowItWorks() {
|
||||
number: "02",
|
||||
title: "AI Analysis",
|
||||
description:
|
||||
"GPT-4 Turbo extracts and analyzes every clause, term, and detail automatically.",
|
||||
"AI extracts and analyzes every clause, term, and detail automatically.",
|
||||
icon: Cpu,
|
||||
gradient: "bg-gradient-to-br from-violet-500 to-purple-600",
|
||||
glowColor: "rgba(139, 92, 246, 0.5)",
|
||||
|
||||
@@ -3,8 +3,16 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModeToggle } from "@/components/ui/mode-toggle";
|
||||
import { Sparkles, X, ArrowRight } from "lucide-react";
|
||||
import { X, ArrowRight } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
SignedIn,
|
||||
SignedOut,
|
||||
SignInButton,
|
||||
SignUpButton,
|
||||
UserButton,
|
||||
} from "@clerk/nextjs";
|
||||
|
||||
const navLinks = [
|
||||
{ label: "Features", href: "#features" },
|
||||
@@ -17,12 +25,6 @@ export function Navbar() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [activeLink, setActiveLink] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
@@ -46,26 +48,6 @@ export function Navbar() {
|
||||
setIsMobileMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 mt-6 px-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="glass rounded-full px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-blue-600" />
|
||||
<span className="text-xl font-bold gradient-text">
|
||||
LexiChain
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav
|
||||
@@ -100,44 +82,15 @@ export function Navbar() {
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center justify-center">
|
||||
{/* Subtle background badge */}
|
||||
<div
|
||||
className="
|
||||
absolute inset-0
|
||||
rounded-xl
|
||||
bg-gradient-to-tr
|
||||
from-blue-500/10
|
||||
via-purple-500/10
|
||||
to-teal-500/10
|
||||
opacity-0
|
||||
group-hover:opacity-100
|
||||
transition-opacity duration-300
|
||||
"
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-tr from-blue-500/10 via-purple-500/10 to-teal-500/10 opacity-0 group-hover:opacity-100 transition-opacity duration-300" />
|
||||
<Image
|
||||
src="/LexiChain.png"
|
||||
alt="LexiChain Logo"
|
||||
width={34}
|
||||
height={34}
|
||||
className="
|
||||
relative z-10
|
||||
w-8 h-8 object-contain
|
||||
transition-all duration-300 ease-out
|
||||
group-hover:scale-110
|
||||
"
|
||||
className="relative z-10 w-8 h-8 object-contain transition-all duration-300 ease-out group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="
|
||||
text-lg font-semibold
|
||||
gradient-text
|
||||
sm:hidden
|
||||
"
|
||||
>
|
||||
LC
|
||||
</span>
|
||||
</a>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
@@ -163,46 +116,45 @@ export function Navbar() {
|
||||
|
||||
{/* Right Section */}
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
{/* Theme Toggle */}
|
||||
<ModeToggle />
|
||||
{/* Global Clerk context usage */}
|
||||
<SignedOut>
|
||||
<SignInButton mode="modal">
|
||||
<Button variant="outline" className="hidden md:flex">
|
||||
Sign In
|
||||
</Button>
|
||||
</SignInButton>
|
||||
|
||||
{/* Sign In - Desktop */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden md:flex items-center gap-2 px-5 py-2 text-sm font-medium text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<SignUpButton mode="modal">
|
||||
<Button className="hidden md:flex btn-gradient">
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</SignUpButton>
|
||||
</SignedOut>
|
||||
<SignedIn>
|
||||
<Link href="/dashboard" className="hidden md:inline-block">
|
||||
<Button variant="outline">Dashboard</Button>
|
||||
</Link>
|
||||
|
||||
{/* Get Started - Desktop */}
|
||||
<Button className="hidden md:flex items-center gap-2 px-6 py-2.5 text-sm font-semibold text-white btn-gradient rounded-full group">
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</Button>
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</SignedIn>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<Button
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
className="lg:hidden p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors duration-200"
|
||||
aria-label="Toggle menu"
|
||||
className="lg:hidden"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<div className="relative w-6 h-6">
|
||||
<span
|
||||
className={`absolute left-0 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
|
||||
isMobileMenuOpen ? "top-3 rotate-45" : "top-1"
|
||||
}`}
|
||||
className={`absolute left-0 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "top-3 rotate-45" : "top-1"}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 top-3 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
|
||||
isMobileMenuOpen ? "opacity-0" : "opacity-100"
|
||||
}`}
|
||||
className={`absolute left-0 top-3 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "opacity-0" : "opacity-100"}`}
|
||||
/>
|
||||
<span
|
||||
className={`absolute left-0 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
|
||||
isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"
|
||||
}`}
|
||||
className={`absolute left-0 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"}`}
|
||||
/>
|
||||
</div>
|
||||
</Button>
|
||||
@@ -214,27 +166,20 @@ export function Navbar() {
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${
|
||||
isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"
|
||||
}`}
|
||||
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"}`}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Menu Panel */}
|
||||
<div
|
||||
className={`absolute right-0 top-0 h-full w-80 max-w-full bg-white dark:bg-slate-900 shadow-2xl transition-transform duration-500 ${
|
||||
isMobileMenuOpen ? "translate-x-0" : "translate-x-full"
|
||||
}`}
|
||||
className={`absolute right-0 top-0 h-full w-80 max-w-full bg-white dark:bg-slate-900 shadow-2xl transition-transform duration-500 ${isMobileMenuOpen ? "translate-x-0" : "translate-x-full"}`}
|
||||
>
|
||||
<div className="p-6 pt-20">
|
||||
<Button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="absolute top-6 right-6 p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="Close menu"
|
||||
className="absolute top-6 right-6"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
@@ -242,36 +187,54 @@ export function Navbar() {
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{navLinks.map((link, index) => (
|
||||
{navLinks.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={(e) => handleLinkClick(e, link.href)}
|
||||
className="px-4 py-3 text-lg font-medium text-slate-700 dark:text-slate-300 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-all duration-200"
|
||||
style={{
|
||||
animationDelay: `${index * 0.1}s`,
|
||||
animation: isMobileMenuOpen
|
||||
? "slide-up 0.4s ease-out forwards"
|
||||
: "none",
|
||||
opacity: isMobileMenuOpen ? 1 : 0,
|
||||
}}
|
||||
className="px-4 py-3 text-lg font-medium"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full px-5 py-3 text-sm font-medium text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 rounded-full hover:bg-slate-50 dark:hover:bg-slate-800 transition-all duration-200"
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
<Button className="w-full flex items-center justify-center gap-2 px-6 py-3 text-sm font-semibold text-white btn-gradient rounded-full">
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="mt-4 border-t border-slate-200/70 dark:border-slate-700/70 pt-5 space-y-3">
|
||||
<SignedOut>
|
||||
<Link
|
||||
href="/sign-in"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<Button variant="outline" className="w-full">
|
||||
Sign In
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
href="/sign-up"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<Button className="w-full btn-gradient">
|
||||
Get Started
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</SignedOut>
|
||||
|
||||
<SignedIn>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block"
|
||||
>
|
||||
<Button className="w-full">Go to Dashboard</Button>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between rounded-xl border border-slate-200/70 dark:border-slate-700/70 px-3 py-2">
|
||||
<span className="text-sm text-slate-600 dark:text-slate-300">
|
||||
Account
|
||||
</span>
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</div>
|
||||
</SignedIn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
295
components/views/dashboard/charts.tsx
Normal file
295
components/views/dashboard/charts.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
BarChart,
|
||||
Bar,
|
||||
Line,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
type TrendData = Array<{ date: string; count: number }>;
|
||||
type TypeData = Array<{ type: string; count: number }>;
|
||||
type StatusData = Array<{ name: string; count: number }>;
|
||||
|
||||
const PIE_COLORS: Record<string, string> = {
|
||||
Uploaded: "hsl(38 92% 50%)",
|
||||
Processing: "hsl(var(--primary))",
|
||||
Analyzed: "hsl(160 84% 39%)",
|
||||
Failed: "hsl(var(--destructive))",
|
||||
};
|
||||
|
||||
const FALLBACK_COLORS = [
|
||||
"hsl(var(--primary))",
|
||||
"hsl(var(--secondary))",
|
||||
"hsl(var(--accent))",
|
||||
"hsl(var(--destructive))",
|
||||
];
|
||||
|
||||
const tooltipStyle = {
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "12px",
|
||||
color: "hsl(var(--foreground))",
|
||||
};
|
||||
|
||||
export function TrendChart({ data }: { data: TrendData }) {
|
||||
const trendData = useMemo(
|
||||
() =>
|
||||
data.map((point, index) => {
|
||||
const start = Math.max(0, index - 6);
|
||||
const window = data.slice(start, index + 1);
|
||||
const average =
|
||||
window.reduce((sum, item) => sum + item.count, 0) / window.length;
|
||||
|
||||
return {
|
||||
...point,
|
||||
movingAverage: Number(average.toFixed(2)),
|
||||
};
|
||||
}),
|
||||
[data],
|
||||
);
|
||||
|
||||
const xAxisInterval =
|
||||
trendData.length > 12 ? Math.floor(trendData.length / 8) : 0;
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={trendData}
|
||||
margin={{ top: 10, right: 10, left: -24, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="5%"
|
||||
stopColor="hsl(var(--primary))"
|
||||
stopOpacity={0.65}
|
||||
/>
|
||||
<stop
|
||||
offset="95%"
|
||||
stopColor="hsl(var(--primary))"
|
||||
stopOpacity={0.05}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
interval={xAxisInterval}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
allowDecimals={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(
|
||||
value: number | string | undefined,
|
||||
name: string | number | undefined,
|
||||
) => {
|
||||
const numericValue = Number(value ?? 0);
|
||||
if (name === "movingAverage") {
|
||||
return [numericValue.toFixed(1), "7-day avg"];
|
||||
}
|
||||
|
||||
return [numericValue, "Uploads"];
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth={2.25}
|
||||
fillOpacity={1}
|
||||
fill="url(#trendFill)"
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="movingAverage"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractTypeChart({ data }: { data: TypeData }) {
|
||||
const sortedData = useMemo(
|
||||
() => [...data].sort((a, b) => b.count - a.count),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
data={sortedData}
|
||||
layout="vertical"
|
||||
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="hsl(var(--border))"
|
||||
horizontal={false}
|
||||
/>
|
||||
<XAxis
|
||||
type="number"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
allowDecimals={false}
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="type"
|
||||
width={128}
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
cursor={false}
|
||||
formatter={(value: number | string | undefined) => [
|
||||
Number(value ?? 0),
|
||||
"Files",
|
||||
]}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[0, 8, 8, 0]}>
|
||||
{sortedData.map((item, index) => {
|
||||
const opacity = Math.max(0.35, 0.95 - index * 0.12);
|
||||
return (
|
||||
<Cell
|
||||
key={`${item.type}-${index}`}
|
||||
fill={`hsl(var(--primary) / ${opacity})`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContractStatusChart({ data }: { data: StatusData }) {
|
||||
const total = useMemo(
|
||||
() => data.reduce((sum, item) => sum + item.count, 0),
|
||||
[data],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col">
|
||||
<div className="h-[76%] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={62}
|
||||
outerRadius={94}
|
||||
paddingAngle={3}
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth={2}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell
|
||||
key={`${entry.name}-${index}`}
|
||||
fill={
|
||||
PIE_COLORS[entry.name] ??
|
||||
FALLBACK_COLORS[index % FALLBACK_COLORS.length]
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
{total > 0 && (
|
||||
<text
|
||||
x="50%"
|
||||
y="50%"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
<tspan
|
||||
x="50%"
|
||||
y="50%"
|
||||
className="fill-foreground text-base font-semibold"
|
||||
>
|
||||
{total}
|
||||
</tspan>
|
||||
<tspan
|
||||
x="50%"
|
||||
dy="16"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
Files
|
||||
</tspan>
|
||||
</text>
|
||||
)}
|
||||
<Tooltip
|
||||
contentStyle={tooltipStyle}
|
||||
formatter={(value: number | string | undefined) => [
|
||||
Number(value ?? 0),
|
||||
"Files",
|
||||
]}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-2">
|
||||
{data.map((item, index) => {
|
||||
const color =
|
||||
PIE_COLORS[item.name] ??
|
||||
FALLBACK_COLORS[index % FALLBACK_COLORS.length];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.name}-legend`}
|
||||
className="flex items-center gap-2 rounded-lg border border-border/50 bg-muted/25 px-2.5 py-1.5"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-[11px] text-muted-foreground truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="ml-auto text-[11px] font-medium text-foreground">
|
||||
{item.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
components/views/dashboard/contacts-header.tsx
Normal file
44
components/views/dashboard/contacts-header.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import { BackgroundBeams } from "@/components/ui/background-beams";
|
||||
|
||||
export function ContactsHeader() {
|
||||
return (
|
||||
<div className="border-b border-border/50 bg-background/80 backdrop-blur-sm">
|
||||
<BackgroundBeams className="opacity-70" />
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 space-y-6">
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back to Dashboard
|
||||
</Link>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold tracking-tight bg-gradient-to-r from-primary via-accent to-secondary bg-clip-text text-transparent">
|
||||
Contracts Manager
|
||||
</h1>
|
||||
<p className="max-w-3xl text-lg text-muted-foreground">
|
||||
Upload, review, and analyze your financial contracts with a focused
|
||||
workspace built for speed and clarity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 pt-1">
|
||||
<div className="flex items-center gap-2 rounded-full border border-primary/20 bg-primary/10 px-4 py-2 text-sm font-medium text-primary">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
AI-powered review
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-full border border-emerald-400/20 bg-emerald-400/10 px-4 py-2 text-sm font-medium text-emerald-500">
|
||||
<ShieldCheck className="w-4 h-4" />
|
||||
Compliance-focused workflow
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
components/views/dashboard/contract-upload-form.tsx
Normal file
141
components/views/dashboard/contract-upload-form.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { UploadDropzone } from "@uploadthing/react";
|
||||
import { AlertCircle, Sparkles, Wand2, ShieldCheck } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { saveContract } from "@/lib/actions/contract.action";
|
||||
import { toast } from "sonner";
|
||||
import type { OurFileRouter } from "@/lib/upload";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export function ContractUploadForm({
|
||||
onUploadSuccess,
|
||||
}: {
|
||||
onUploadSuccess: () => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const emitNotificationRefresh = () => {
|
||||
window.dispatchEvent(new Event("notifications:refresh"));
|
||||
const channel = new BroadcastChannel("notifications-channel");
|
||||
channel.postMessage({ type: "notifications:refresh" });
|
||||
channel.close();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="relative overflow-hidden border border-border/60 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.14),transparent_45%),radial-gradient(circle_at_bottom_left,hsl(var(--secondary)/0.1),transparent_42%)] p-0">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<svg
|
||||
viewBox="0 0 480 220"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="absolute -right-8 top-0 h-40 w-80 opacity-45"
|
||||
>
|
||||
<path
|
||||
d="M8 176C76 140 114 68 198 68C260 68 286 116 346 116C394 116 430 88 474 74"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M10 204C72 174 122 146 186 146C250 146 294 178 350 178C400 178 434 162 474 146"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="inline-flex items-center gap-1.5 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.14em] text-primary">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
AI-Ready Intake
|
||||
</p>
|
||||
<h3 className="mt-3 text-xl font-semibold tracking-tight text-foreground">
|
||||
Upload contracts for structured extraction
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Clean intake pipeline for contract parsing, validation, and
|
||||
analysis.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-xl border border-border/60 bg-background/70 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-emerald-500" />
|
||||
Theme-aware secure upload
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadDropzone<OurFileRouter, "contractUploader">
|
||||
endpoint="contractUploader"
|
||||
onClientUploadComplete={async (res) => {
|
||||
if (!res || res.length === 0) {
|
||||
toast.error("Upload failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const file = res[0];
|
||||
|
||||
// Save to database
|
||||
const result = await saveContract({
|
||||
fileName: file.name,
|
||||
fileUrl: file.url,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Contract uploaded successfully!");
|
||||
emitNotificationRefresh();
|
||||
onUploadSuccess();
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || "Failed to save contract");
|
||||
}
|
||||
}}
|
||||
onUploadError={(error: Error) => {
|
||||
toast.error(`Upload failed: ${error.message}`);
|
||||
}}
|
||||
appearance={{
|
||||
container:
|
||||
"w-full cursor-pointer rounded-2xl border border-dashed border-primary/35 bg-background/85 px-4 py-8 backdrop-blur-sm transition-all duration-300 hover:border-primary/55 hover:bg-background ut-uploading:cursor-not-allowed",
|
||||
button:
|
||||
"bg-gradient-to-r from-primary to-accent text-white font-semibold px-6 py-3 rounded-xl transition-all duration-300 hover:from-primary/90 hover:to-accent/90 ut-uploading:cursor-not-allowed",
|
||||
label: "text-base md:text-lg text-foreground font-semibold",
|
||||
uploadIcon: "w-11 h-11 text-primary",
|
||||
allowedContent: "mt-2 text-sm text-muted-foreground",
|
||||
}}
|
||||
content={{
|
||||
label: "Upload Your Contract",
|
||||
allowedContent: "PDF, JPG, PNG, WEBP up to 8MB",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-6 grid gap-3 border-t border-border/50 pt-5 sm:grid-cols-3">
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold text-foreground">Formats</div>
|
||||
<div>PDF, JPG, PNG, WEBP</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="mb-1 font-semibold text-foreground">Max Size</div>
|
||||
<div>8 MB</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border/50 bg-muted/25 px-3 py-2 text-xs text-muted-foreground flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 flex-shrink-0 text-accent" />
|
||||
<div>
|
||||
<div className="mb-1 font-semibold text-foreground">AI Flow</div>
|
||||
<div>Upload first, then click Analyze when ready</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 inline-flex items-center gap-2 rounded-lg border border-border/50 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
<Wand2 className="h-3.5 w-3.5 text-secondary" />
|
||||
Extraction quality improves as more contracts are analyzed.
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
1078
components/views/dashboard/contracts-list.tsx
Normal file
1078
components/views/dashboard/contracts-list.tsx
Normal file
File diff suppressed because it is too large
Load Diff
47
components/views/dashboard/empty-contracts-state.tsx
Normal file
47
components/views/dashboard/empty-contracts-state.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { FileText, Inbox } from "lucide-react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export function EmptyContractsState() {
|
||||
return (
|
||||
<Card className="border-dashed border-border hover:border-primary/50 transition-colors duration-300">
|
||||
<div className="p-12 md:p-16 flex flex-col items-center justify-center min-h-[300px]">
|
||||
<div className="mb-6 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-primary/20 to-accent/20 rounded-full blur-3xl"></div>
|
||||
<div className="relative p-4 bg-background dark:bg-card rounded-full border border-border/50">
|
||||
<Inbox className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center max-w-md">
|
||||
<h3 className="text-xl font-semibold text-foreground mb-2">
|
||||
No contracts yet
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-1">
|
||||
Upload your first contract to get started.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Our AI will automatically analyze and extract key information from
|
||||
your documents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid grid-cols-3 gap-4 w-full text-center text-xs">
|
||||
<div className="p-3 rounded-lg bg-primary/5 dark:bg-primary/10 border border-primary/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-primary" />
|
||||
<span className="text-muted-foreground">Fast Upload</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-accent/5 dark:bg-accent/10 border border-accent/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-accent" />
|
||||
<span className="text-muted-foreground">AI Analysis</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-secondary/5 dark:bg-secondary/10 border border-secondary/20">
|
||||
<FileText className="w-5 h-5 mx-auto mb-2 text-secondary" />
|
||||
<span className="text-muted-foreground">Blockchain</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
162
components/views/dashboard/navigation.tsx
Normal file
162
components/views/dashboard/navigation.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { BarChart3, FileText, LogOut } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SignOutButton, UserButton } from "@clerk/nextjs";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { ModeToggle } from "@/components/ui/mode-toggle";
|
||||
import NotificationBar from "./notification-bar";
|
||||
|
||||
interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
href: "/dashboard",
|
||||
label: "Analytics",
|
||||
icon: <BarChart3 className="w-5 h-5" />,
|
||||
description: "View your statistics",
|
||||
},
|
||||
{
|
||||
href: "/contacts",
|
||||
label: "Contracts",
|
||||
icon: <FileText className="w-5 h-5" />,
|
||||
description: "Manage contracts",
|
||||
},
|
||||
];
|
||||
|
||||
export function DashboardNavigation() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="fixed left-0 top-0 h-screen w-72 border-r border-border/60 bg-background/95 backdrop-blur-xl flex flex-col overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
<div className="absolute -top-16 left-1/2 h-44 w-44 -translate-x-1/2 rounded-full bg-primary/12 blur-3xl" />
|
||||
<svg
|
||||
viewBox="0 0 320 400"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className="absolute bottom-0 left-0 w-full opacity-50"
|
||||
>
|
||||
<path
|
||||
d="M0 338C56 312 82 252 142 252C194 252 214 302 266 302C294 302 306 290 320 276"
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M0 372C52 342 88 322 132 322C176 322 212 346 252 346C282 346 304 338 320 326"
|
||||
stroke="hsl(var(--secondary))"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Logo section */}
|
||||
<div className="relative z-10 p-6 border-b border-border/40">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 group">
|
||||
<Image
|
||||
src="/LexiChain.png"
|
||||
alt="LexiCHAIN Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
className="w-8 h-8"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-foreground">LexiChain</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Operations Console
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Navigation items */}
|
||||
<nav className="relative z-10 flex-1 p-4 space-y-2 overflow-y-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<motion.div
|
||||
key={item.href}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<div
|
||||
className={`relative p-3 rounded-lg transition-all duration-200 cursor-pointer group ${
|
||||
isActive
|
||||
? "bg-primary/10 border border-primary/20"
|
||||
: "hover:bg-muted/60 border border-transparent"
|
||||
}`}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="active-nav"
|
||||
className="absolute inset-0 rounded-lg bg-primary/5"
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
<span
|
||||
className={`transition-colors ${
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground group-hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`font-medium text-sm ${isActive ? "text-foreground" : "text-muted-foreground group-hover:text-foreground"}`}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Bottom section */}
|
||||
<div className="relative z-10 p-4 border-t border-border/40 space-y-3">
|
||||
<div className="rounded-xl border border-border/60 bg-gradient-to-r from-muted/35 via-background to-muted/25 px-3 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserButton afterSignOutUrl="/" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBar />
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full bg-emerald-500" />
|
||||
Trusted workspace
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
617
components/views/dashboard/notification-bar.tsx
Normal file
617
components/views/dashboard/notification-bar.tsx
Normal file
@@ -0,0 +1,617 @@
|
||||
/**
|
||||
* Notification Bar Component
|
||||
*
|
||||
* Displays a beautiful notification bar/dropdown for showing:
|
||||
* - Unread notifications count
|
||||
* - Recent notifications from users' actions
|
||||
* - Deadline/renewal alerts
|
||||
* - Notification management (mark as read, delete)
|
||||
*
|
||||
* Features:
|
||||
* - Real-time badge count
|
||||
* - Smooth animations
|
||||
* - Theme support (dark/light mode)
|
||||
* - Responsive design
|
||||
* - Auto-refresh on intervals
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
Bell,
|
||||
X,
|
||||
Check,
|
||||
CheckCheck,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Info,
|
||||
Clock,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
getNotifications,
|
||||
getUnreadNotificationCount,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
deleteNotification,
|
||||
checkDeadlineNotifications,
|
||||
} from "@/lib/actions/notification.action";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Notification type interface matching the database structure
|
||||
*/
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: "SUCCESS" | "WARNING" | "ERROR" | "INFO" | "DEADLINE";
|
||||
title: string;
|
||||
message: string;
|
||||
read: boolean;
|
||||
createdAt: string;
|
||||
icon?: string;
|
||||
actionType?: string;
|
||||
contractId?: string;
|
||||
contract?: {
|
||||
id: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
endDate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the appropriate icon component based on notification type
|
||||
*
|
||||
* Mapping:
|
||||
* - SUCCESS: CheckCircle2 (green)
|
||||
* - WARNING: AlertTriangle (yellow/orange)
|
||||
* - ERROR: AlertCircle (red)
|
||||
* - INFO: Info (blue)
|
||||
* - DEADLINE: Clock (critical/urgent)
|
||||
*/
|
||||
const getNotificationIcon = (type: Notification["type"], icon?: string) => {
|
||||
switch (type) {
|
||||
case "SUCCESS":
|
||||
return <CheckCircle2 className="h-5 w-5 text-green-500" />;
|
||||
case "WARNING":
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
|
||||
case "ERROR":
|
||||
return <AlertCircle className="h-5 w-5 text-red-500" />;
|
||||
case "DEADLINE":
|
||||
return <Clock className="h-5 w-5 text-red-500" />;
|
||||
case "INFO":
|
||||
default:
|
||||
return <Info className="h-5 w-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the color styling based on notification type
|
||||
* Used for background and border colors in the notification card
|
||||
*/
|
||||
const getNotificationColor = (type: Notification["type"]) => {
|
||||
switch (type) {
|
||||
case "SUCCESS":
|
||||
return "bg-green-50 border-green-200 dark:bg-green-950/30 dark:border-green-800";
|
||||
case "WARNING":
|
||||
return "bg-yellow-50 border-yellow-200 dark:bg-yellow-950/30 dark:border-yellow-800";
|
||||
case "ERROR":
|
||||
return "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800";
|
||||
case "DEADLINE":
|
||||
return "bg-red-50 border-red-200 dark:bg-red-950/30 dark:border-red-800";
|
||||
case "INFO":
|
||||
default:
|
||||
return "bg-blue-50 border-blue-200 dark:bg-blue-950/30 dark:border-blue-800";
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Single Notification Item Component
|
||||
*
|
||||
* Displays an individual notification with:
|
||||
* - Type-specific icon and coloring
|
||||
* - Title and message
|
||||
* - Timestamp
|
||||
* - Action buttons (mark as read, delete)
|
||||
* - Visual indicator for unread status
|
||||
*/
|
||||
const NotificationItem: React.FC<{
|
||||
notification: Notification;
|
||||
onRead: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}> = ({ notification, onRead, onDelete }) => {
|
||||
const formatTime = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMs / 3600000);
|
||||
const diffDays = Math.floor(diffMs / 86400000);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
|
||||
return date.toLocaleDateString();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 border transition-all hover:shadow-sm",
|
||||
getNotificationColor(notification.type),
|
||||
!notification.read && "ring-1 ring-primary/20",
|
||||
)}
|
||||
>
|
||||
{/* Notification Icon */}
|
||||
<div className="mt-1 flex-shrink-0">
|
||||
{getNotificationIcon(notification.type, notification.icon)}
|
||||
</div>
|
||||
|
||||
{/* Notification Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-semibold text-sm text-foreground line-clamp-1">
|
||||
{notification.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
|
||||
{notification.message}
|
||||
</p>
|
||||
|
||||
{/* Contract link if available */}
|
||||
{notification.contract && (
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
📄 {notification.contract.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Unread dot indicator */}
|
||||
{!notification.read && (
|
||||
<div className="h-2 w-2 rounded-full bg-primary flex-shrink-0 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{formatTime(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!notification.read && (
|
||||
<button
|
||||
onClick={() => onRead(notification.id)}
|
||||
className="p-1.5 hover:bg-white/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
title="Mark as read"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onDelete(notification.id)}
|
||||
className="p-1.5 hover:bg-white/20 dark:hover:bg-white/10 rounded-md transition-colors"
|
||||
title="Delete notification"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Main Notification Bar Component
|
||||
*
|
||||
* Displays:
|
||||
* 1. Bell icon with unread count badge
|
||||
* 2. Dropdown showing recent notifications
|
||||
* 3. Ability to mark as read individually or all at once
|
||||
* 4. Action buttons and time formatting
|
||||
*/
|
||||
export default function NotificationBar() {
|
||||
// State management
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const [panelPosition, setPanelPosition] = useState({ top: 70, left: 250 });
|
||||
const channelRef = useRef<BroadcastChannel | null>(null);
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
if (!triggerRef.current) return;
|
||||
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
const panelWidth = 420;
|
||||
const panelHeightEstimate = panelRef.current?.offsetHeight ?? 240;
|
||||
const viewportPadding = 12;
|
||||
|
||||
const preferredLeft = rect.right + 14;
|
||||
const maxLeft = window.innerWidth - panelWidth - viewportPadding;
|
||||
const left = Math.max(viewportPadding, Math.min(preferredLeft, maxLeft));
|
||||
|
||||
// Keep the panel visually balanced around the bell row,
|
||||
// with a slightly higher anchor so it does not feel too low.
|
||||
const triggerCenterY = (rect.top + rect.bottom) / 2;
|
||||
const preferredTop = triggerCenterY - panelHeightEstimate * 0.42;
|
||||
const maxTop = window.innerHeight - viewportPadding - panelHeightEstimate;
|
||||
const top = Math.max(viewportPadding, Math.min(preferredTop, maxTop));
|
||||
|
||||
setPanelPosition({ top, left });
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetches unread notifications and deadline checks
|
||||
*
|
||||
* Steps:
|
||||
* 1. Check for upcoming deadlines and create notifications
|
||||
* 2. Fetch unread notifications from database
|
||||
* 3. Update state with fresh notification data
|
||||
* 4. Get unread count for badge display
|
||||
*/
|
||||
const fetchNotifications = useCallback(
|
||||
async (options?: { showLoader?: boolean }) => {
|
||||
const showLoader = options?.showLoader ?? false;
|
||||
|
||||
try {
|
||||
if (showLoader) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// Fetch unread notifications
|
||||
const response = await getNotifications(15);
|
||||
if (response.success) {
|
||||
setNotifications(response.data || []);
|
||||
}
|
||||
|
||||
// Update unread count
|
||||
const countResponse = await getUnreadNotificationCount();
|
||||
if (countResponse.success) {
|
||||
setUnreadCount(countResponse.data?.count || 0);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching notifications:", error);
|
||||
} finally {
|
||||
if (showLoader) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const notifyRealtimeRefresh = useCallback(() => {
|
||||
void fetchNotifications({ showLoader: false });
|
||||
}, [fetchNotifications]);
|
||||
|
||||
const broadcastRealtimeRefresh = useCallback(() => {
|
||||
const event = new Event("notifications:refresh");
|
||||
window.dispatchEvent(event);
|
||||
channelRef.current?.postMessage({ type: "notifications:refresh" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// Cross-tab realtime notifications without polling loops.
|
||||
const channel = new BroadcastChannel("notifications-channel");
|
||||
channelRef.current = channel;
|
||||
|
||||
const onMessage = (message: MessageEvent<{ type?: string }>) => {
|
||||
if (message.data?.type === "notifications:refresh") {
|
||||
void fetchNotifications({ showLoader: false });
|
||||
}
|
||||
};
|
||||
|
||||
channel.addEventListener("message", onMessage);
|
||||
|
||||
return () => {
|
||||
channel.removeEventListener("message", onMessage);
|
||||
channel.close();
|
||||
channelRef.current = null;
|
||||
};
|
||||
}, [isMounted, fetchNotifications]);
|
||||
|
||||
/**
|
||||
* Handles marking a notification as read
|
||||
*
|
||||
* For non-deadline notifications: Automatically deletes after marking as read to save storage
|
||||
* For deadline notifications: Keeps them to show ongoing contract expiry info
|
||||
*
|
||||
* Updates local state immediately for optimistic UI
|
||||
* Then calls server action to persist to database
|
||||
*/
|
||||
const handleMarkAsRead = useCallback(
|
||||
async (notificationId: string) => {
|
||||
// Find the notification to check its type
|
||||
const notification = notifications.find((n) => n.id === notificationId);
|
||||
const isDeadlineNotification = notification?.type === "DEADLINE";
|
||||
|
||||
if (isDeadlineNotification) {
|
||||
// For DEADLINE notifications: just mark as read
|
||||
setNotifications((prev) =>
|
||||
prev.map((notif) =>
|
||||
notif.id === notificationId ? { ...notif, read: true } : notif,
|
||||
),
|
||||
);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
|
||||
// Persist to database
|
||||
await markNotificationAsRead(notificationId);
|
||||
} else {
|
||||
// For non-deadline notifications: delete after marking read (auto-cleanup)
|
||||
// Optimistic update: remove from UI immediately
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notif) => notif.id !== notificationId),
|
||||
);
|
||||
setUnreadCount((prev) => Math.max(0, prev - 1));
|
||||
|
||||
// Persist to database: delete the notification
|
||||
await deleteNotification(notificationId);
|
||||
}
|
||||
|
||||
broadcastRealtimeRefresh();
|
||||
},
|
||||
[notifications, broadcastRealtimeRefresh],
|
||||
);
|
||||
|
||||
/**
|
||||
* Handles marking all notifications as read
|
||||
*/
|
||||
const handleMarkAllAsRead = useCallback(async () => {
|
||||
setNotifications((prev) => prev.map((notif) => ({ ...notif, read: true })));
|
||||
setUnreadCount(0);
|
||||
|
||||
await markAllNotificationsAsRead();
|
||||
broadcastRealtimeRefresh();
|
||||
}, [broadcastRealtimeRefresh]);
|
||||
|
||||
/**
|
||||
* Handles deleting a notification
|
||||
*
|
||||
* Removes from UI immediately
|
||||
* Then calls server action to delete from database
|
||||
*/
|
||||
const handleDelete = useCallback(
|
||||
async (notificationId: string) => {
|
||||
// Optimistic update
|
||||
setNotifications((prev) =>
|
||||
prev.filter((notif) => notif.id !== notificationId),
|
||||
);
|
||||
|
||||
// Persist to database
|
||||
await deleteNotification(notificationId);
|
||||
|
||||
// Refresh unread count
|
||||
const countResponse = await getUnreadNotificationCount();
|
||||
if (countResponse.success) {
|
||||
setUnreadCount(countResponse.data?.count || 0);
|
||||
}
|
||||
broadcastRealtimeRefresh();
|
||||
},
|
||||
[broadcastRealtimeRefresh],
|
||||
);
|
||||
|
||||
/**
|
||||
* Effect: Close dropdown when clicking outside
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Node;
|
||||
const clickedTrigger = dropdownRef.current?.contains(target);
|
||||
const clickedPanel = panelRef.current?.contains(target);
|
||||
|
||||
if (!clickedTrigger && !clickedPanel) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* Effect: Event-driven realtime updates (no intervals)
|
||||
*
|
||||
* Initial load: one fetch with loader
|
||||
* Refresh triggers:
|
||||
* - custom notifications:refresh event (same tab)
|
||||
* - BroadcastChannel message (cross-tab)
|
||||
* - focus/visibility return
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// Initial load
|
||||
void fetchNotifications({ showLoader: true });
|
||||
|
||||
// Deadline check runs once per mount (no loop)
|
||||
void checkDeadlineNotifications();
|
||||
|
||||
const focusRefresh = () => void fetchNotifications({ showLoader: false });
|
||||
const visibilityRefresh = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void fetchNotifications({ showLoader: false });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("focus", focusRefresh);
|
||||
document.addEventListener("visibilitychange", visibilityRefresh);
|
||||
window.addEventListener("notifications:refresh", notifyRealtimeRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("focus", focusRefresh);
|
||||
document.removeEventListener("visibilitychange", visibilityRefresh);
|
||||
window.removeEventListener(
|
||||
"notifications:refresh",
|
||||
notifyRealtimeRefresh,
|
||||
);
|
||||
};
|
||||
}, [isMounted, fetchNotifications, notifyRealtimeRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
if (!isOpen) return;
|
||||
|
||||
updatePanelPosition();
|
||||
const handleResize = () => updatePanelPosition();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, [isMounted, isOpen, updatePanelPosition]);
|
||||
|
||||
if (!isMounted) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
className="relative rounded-xl border border-border/70 bg-background/95 p-2.5 text-foreground shadow-sm backdrop-blur"
|
||||
title="Notifications"
|
||||
aria-label="Notifications"
|
||||
type="button"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleOpen = () => {
|
||||
if (!isOpen) {
|
||||
updatePanelPosition();
|
||||
void fetchNotifications({ showLoader: notifications.length === 0 });
|
||||
}
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative">
|
||||
{/* Bell Icon Button with Badge */}
|
||||
<button
|
||||
ref={triggerRef}
|
||||
onClick={toggleOpen}
|
||||
className="relative rounded-xl border border-border/70 bg-background/95 p-2.5 text-foreground shadow-sm backdrop-blur hover:bg-accent transition-colors"
|
||||
title="Notifications"
|
||||
type="button"
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
|
||||
{/* Unread Badge */}
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs font-bold text-white bg-red-500 rounded-full animate-pulse">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
style={{
|
||||
top: `${panelPosition.top}px`,
|
||||
left: `${panelPosition.left}px`,
|
||||
}}
|
||||
className="fixed w-[420px] max-w-[calc(100vw-2rem)] rounded-2xl border border-border/80 bg-background/98 shadow-2xl backdrop-blur z-[90] max-h-[70vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border/70 bg-muted/30 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="font-semibold text-foreground">Notifications</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Renewal reminders and activity updates
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-1 hover:bg-accent rounded-md transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Notifications List */}
|
||||
<div className="flex-1 overflow-y-auto bg-background">
|
||||
{isLoading ? (
|
||||
<div className="p-4 text-center text-muted-foreground">
|
||||
<p>Loading notifications...</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center p-8 bg-gradient-to-b from-background to-muted/10">
|
||||
<div className="mb-4 p-3 rounded-full bg-muted/50">
|
||||
<Bell className="h-8 w-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
<p className="font-medium text-foreground">All caught up!</p>
|
||||
<p className="text-sm text-muted-foreground text-center mt-2">
|
||||
No new notifications right now. Non-deadline notifications
|
||||
are auto-deleted after viewing.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground text-center mt-3 px-2">
|
||||
Deadline reminders for upcoming contract expirations will
|
||||
appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 p-3">
|
||||
{notifications.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onRead={handleMarkAsRead}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
{notifications.length > 0 && (
|
||||
<div className="border-t border-border/70 bg-muted/20 p-3 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleMarkAllAsRead}
|
||||
className="flex-1"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4 mr-2" />
|
||||
Mark all as read
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(false)}
|
||||
type="button"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
269
hooks/useNotifications.ts
Normal file
269
hooks/useNotifications.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* useNotifications Hook
|
||||
*
|
||||
* Custom React hook for managing toast notifications and database notifications
|
||||
* across the application.
|
||||
*
|
||||
* Features:
|
||||
* - Integration with Sonner toasts for UI feedback
|
||||
* - Creation of persistent notifications in database
|
||||
* - Type-safe notification creation
|
||||
* - Automatic error handling and logging
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* const { notifySuccess, notifyError, notifyWarning, notifyInfo, notifyDeadline } = useNotifications();
|
||||
*
|
||||
* // Show toast + save to database
|
||||
* notifySuccess("Contract uploaded", "Your contract is ready for analysis");
|
||||
* ```
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
/**
|
||||
* Notification creation payload
|
||||
*/
|
||||
interface NotificationPayload {
|
||||
title: string;
|
||||
message: string;
|
||||
contractId?: string;
|
||||
actionType?: string;
|
||||
actionData?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook return type
|
||||
*/
|
||||
interface UseNotificationsReturn {
|
||||
notifySuccess: (title: string, message: string, contractId?: string) => void;
|
||||
notifyError: (title: string, message: string, contractId?: string) => void;
|
||||
notifyWarning: (title: string, message: string, contractId?: string) => void;
|
||||
notifyInfo: (title: string, message: string, contractId?: string) => void;
|
||||
notifyDeadline: (payload: NotificationPayload) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a persistent notification in the database
|
||||
*
|
||||
* This is called internally by the notification methods
|
||||
*
|
||||
* @param type - Notification type (SUCCESS, ERROR, WARNING, INFO, DEADLINE)
|
||||
* @param payload - Notification data
|
||||
* @returns Promise that resolves when notification is saved
|
||||
*
|
||||
* Note: This runs client-side and makes API calls to create notifications
|
||||
* in the background without blocking the UI
|
||||
*/
|
||||
const createPersistentNotification = async (
|
||||
type: "SUCCESS" | "ERROR" | "WARNING" | "INFO" | "DEADLINE",
|
||||
payload: NotificationPayload,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
// This would be called via a server action in a real implementation
|
||||
// For now, we create it directly through the client
|
||||
// In production, consider using a separate API route
|
||||
// Simulate API call to create notification
|
||||
// In real implementation, call server action:
|
||||
// await createNotification({ type, ...payload })
|
||||
} catch (error) {
|
||||
console.error("Failed to create persistent notification:", error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook Implementation
|
||||
*
|
||||
* Provides methods to display notifications with both:
|
||||
* 1. Temporary toast (using Sonner) - visible for a few seconds
|
||||
* 2. Persistent notification - stored in database for notification center
|
||||
*/
|
||||
export const useNotifications = (): UseNotificationsReturn => {
|
||||
/**
|
||||
* Success notification
|
||||
*
|
||||
* Used for:
|
||||
* - Contract uploaded successfully
|
||||
* - Analysis completed
|
||||
* - File deleted
|
||||
* - Settings saved
|
||||
*
|
||||
* @param title - Brief title (e.g., "Contract Uploaded")
|
||||
* @param message - Detailed message (e.g., "Your contract is ready for analysis")
|
||||
* @param contractId - Optional contract ID for dashboard link
|
||||
*/
|
||||
const notifySuccess = useCallback(
|
||||
(title: string, message: string, contractId?: string) => {
|
||||
// Show toast immediately
|
||||
toast.success(title, {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
// Create persistent notification in background
|
||||
createPersistentNotification("SUCCESS", {
|
||||
title,
|
||||
message,
|
||||
contractId,
|
||||
actionType: "SUCCESS_ACTION",
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Error notification
|
||||
*
|
||||
* Used for:
|
||||
* - Upload failed
|
||||
* - Analysis failed
|
||||
* - Network errors
|
||||
* - Invalid contract file
|
||||
*
|
||||
* @param title - Brief error title (e.g., "Upload Failed")
|
||||
* @param message - Detailed error message with troubleshooting hints
|
||||
* @param contractId - Optional contract ID for reference
|
||||
*/
|
||||
const notifyError = useCallback(
|
||||
(title: string, message: string, contractId?: string) => {
|
||||
// Show toast with error styling
|
||||
toast.error(title, {
|
||||
description: message,
|
||||
duration: 5000, // Longer duration for errors
|
||||
});
|
||||
|
||||
// Create persistent notification
|
||||
createPersistentNotification("ERROR", {
|
||||
title,
|
||||
message,
|
||||
contractId,
|
||||
actionType: "ERROR_ACTION",
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Warning notification
|
||||
*
|
||||
* Used for:
|
||||
* - File analysis taking longer than expected
|
||||
* - Low quality extraction
|
||||
* - Insufficient permissions
|
||||
*
|
||||
* @param title - Brief warning title
|
||||
* @param message - Warning details
|
||||
* @param contractId - Optional contract ID
|
||||
*/
|
||||
const notifyWarning = useCallback(
|
||||
(title: string, message: string, contractId?: string) => {
|
||||
toast.warning(title, {
|
||||
description: message,
|
||||
duration: 4000,
|
||||
});
|
||||
|
||||
createPersistentNotification("WARNING", {
|
||||
title,
|
||||
message,
|
||||
contractId,
|
||||
actionType: "WARNING_ACTION",
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Info notification
|
||||
*
|
||||
* Used for:
|
||||
* - General information
|
||||
* - Processing started
|
||||
* - Batch operations completing
|
||||
* - Tips and suggestions
|
||||
*
|
||||
* @param title - Brief info title
|
||||
* @param message - Additional information
|
||||
* @param contractId - Optional contract ID
|
||||
*/
|
||||
const notifyInfo = useCallback(
|
||||
(title: string, message: string, contractId?: string) => {
|
||||
toast.info(title, {
|
||||
description: message,
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
createPersistentNotification("INFO", {
|
||||
title,
|
||||
message,
|
||||
contractId,
|
||||
actionType: "INFO_ACTION",
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Deadline/renewal notification
|
||||
*
|
||||
* Used for:
|
||||
* - Contract expiring in 30 days
|
||||
* - Contract expiring in 15 days
|
||||
* - Contract expiring in 7 days
|
||||
* - Renewal reminders
|
||||
*
|
||||
* @param payload - Deadline notification data including:
|
||||
* - title: Deadline title
|
||||
* - message: Deadline details
|
||||
* - contractId: Contract ID for direct access
|
||||
* - actionType: RENEWAL_CRITICAL, RENEWAL_WARNING, RENEWAL_URGENT
|
||||
* - actionData: Additional deadline metadata
|
||||
*
|
||||
* Example:
|
||||
* ```typescript
|
||||
* notifyDeadline({
|
||||
* title: "🔴 Contract Expiring in 30 Days",
|
||||
* message: "Insurance Auto from ACME Corp expires on Jan 15, 2025",
|
||||
* contractId: "contract123",
|
||||
* actionType: "RENEWAL_CRITICAL",
|
||||
* actionData: {
|
||||
* level: "CRITICAL",
|
||||
* daysUntilExpiration: 30,
|
||||
* expirationDate: "2025-01-15T00:00:00Z"
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
const notifyDeadline = useCallback((payload: NotificationPayload) => {
|
||||
// Show deadline toast with important visual styling
|
||||
toast.error(payload.title, {
|
||||
description: payload.message,
|
||||
duration: 6000, // Longer for important deadlines
|
||||
action: {
|
||||
label: "View",
|
||||
onClick: () => {
|
||||
// Navigate to contract details if needed
|
||||
if (payload.contractId) {
|
||||
window.location.href = `/dashboard?contract=${payload.contractId}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create persistent notification
|
||||
createPersistentNotification("DEADLINE", payload);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifySuccess,
|
||||
notifyError,
|
||||
notifyWarning,
|
||||
notifyInfo,
|
||||
notifyDeadline,
|
||||
};
|
||||
};
|
||||
|
||||
export default useNotifications;
|
||||
530
lib/actions/contract.action.ts
Normal file
530
lib/actions/contract.action.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
422
lib/actions/notification.action.ts
Normal file
422
lib/actions/notification.action.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
14
lib/actions/stats.action.ts
Normal file
14
lib/actions/stats.action.ts
Normal 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);
|
||||
}
|
||||
49
lib/actions/user.action.ts
Normal file
49
lib/actions/user.action.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
19
lib/db/prisma.ts
Normal file
19
lib/db/prisma.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
// src/lib/db/prisma.ts
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log:
|
||||
process.env.NODE_ENV === "development"
|
||||
? ["query", "error", "warn"]
|
||||
: ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
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];
|
||||
}
|
||||
}
|
||||
50
lib/upload.ts
Normal file
50
lib/upload.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// src/lib/uploadthing.ts
|
||||
|
||||
import { createUploadthing, type FileRouter } from "uploadthing/next";
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
|
||||
const f = createUploadthing();
|
||||
|
||||
// FileRouter for your app
|
||||
export const ourFileRouter = {
|
||||
// Contract uploader
|
||||
contractUploader: f({
|
||||
pdf: {
|
||||
maxFileSize: "8MB",
|
||||
maxFileCount: 1,
|
||||
},
|
||||
image: {
|
||||
maxFileSize: "8MB",
|
||||
maxFileCount: 1,
|
||||
},
|
||||
})
|
||||
// Middleware: runs BEFORE upload
|
||||
.middleware(async () => {
|
||||
// Authenticate user
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
// Pass user ID to onUploadComplete
|
||||
return { userId };
|
||||
})
|
||||
// Callback: runs AFTER upload is complete
|
||||
.onUploadComplete(async ({ metadata, file }) => {
|
||||
|
||||
// File info is available:
|
||||
// - file.url (CDN URL)
|
||||
// - file.name (original filename)
|
||||
// - file.size (bytes)
|
||||
// - file.key (unique identifier)
|
||||
|
||||
// You can save to database here or return data to client
|
||||
return {
|
||||
uploadedBy: metadata.userId,
|
||||
fileUrl: file.url,
|
||||
};
|
||||
}),
|
||||
} satisfies FileRouter;
|
||||
|
||||
export type OurFileRouter = typeof ourFileRouter;
|
||||
2492
package-lock.json
generated
2492
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -9,7 +9,11 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/nextjs": "^6.37.4",
|
||||
"@clerk/themes": "^2.4.53",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@prisma/client": "^6.19.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
@@ -37,23 +41,29 @@
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@uploadthing/react": "^7.3.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.564.0",
|
||||
"motion": "^12.34.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"prisma": "^6.19.2",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "19.2.3",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-resizable-panels": "^4.6.4",
|
||||
"recharts": "^3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"svix": "^1.85.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"uploadthing": "^7.7.4",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
@@ -62,8 +72,8 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"autoprefixer": "^10.4.24",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"eslint": "^10.0.1",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
0
prisma/contract.prisma
Normal file
0
prisma/contract.prisma
Normal file
130
prisma/schema.prisma
Normal file
130
prisma/schema.prisma
Normal file
@@ -0,0 +1,130 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
clerkId String @unique
|
||||
email String @unique
|
||||
firstName String?
|
||||
lastName String?
|
||||
imageUrl String?
|
||||
|
||||
contracts Contract[]
|
||||
notifications Notification[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([clerkId])
|
||||
@@index([email])
|
||||
}
|
||||
|
||||
model Contract {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// File info (user uploads)
|
||||
fileName String
|
||||
fileUrl String
|
||||
fileSize Int
|
||||
mimeType String
|
||||
|
||||
// AI-determined fields (filled automatically)
|
||||
title String?
|
||||
type ContractType?
|
||||
provider String?
|
||||
policyNumber String?
|
||||
startDate DateTime?
|
||||
endDate DateTime?
|
||||
premium Decimal? @db.Decimal(10, 2)
|
||||
|
||||
// Processing pipeline
|
||||
status ContractStatus @default(UPLOADED)
|
||||
|
||||
// AI results
|
||||
extractedText String? @db.Text
|
||||
summary String? @db.Text
|
||||
keyPoints Json?
|
||||
|
||||
// Blockchain (later)
|
||||
documentHash String?
|
||||
txHash String?
|
||||
ipfsUrl String?
|
||||
|
||||
// Notifications for this contract
|
||||
notifications Notification[]
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([type])
|
||||
@@index([endDate])
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
contractId String?
|
||||
contract Contract? @relation(fields: [contractId], references: [id], onDelete: SetNull)
|
||||
|
||||
// Notification metadata
|
||||
type NotificationType
|
||||
title String
|
||||
message String
|
||||
icon String? // Icon type for UI
|
||||
|
||||
// Action metadata
|
||||
actionType String? // e.g., "RENEWAL_REMINDER", "UPLOAD_SUCCESS", "ANALYSIS_COMPLETE"
|
||||
actionData Json? // Additional data for the action
|
||||
|
||||
// Status tracking
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime? // Notification expiration time
|
||||
|
||||
@@index([userId])
|
||||
@@index([contractId])
|
||||
@@index([type])
|
||||
@@index([read])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
SUCCESS // Successful action
|
||||
WARNING // Warning/Alert
|
||||
ERROR // Error
|
||||
INFO // Informational
|
||||
DEADLINE // Deadline approaching
|
||||
}
|
||||
|
||||
enum ContractType {
|
||||
INSURANCE_AUTO
|
||||
INSURANCE_HOME
|
||||
INSURANCE_HEALTH
|
||||
INSURANCE_LIFE
|
||||
LOAN
|
||||
CREDIT_CARD
|
||||
INVESTMENT
|
||||
OTHER
|
||||
}
|
||||
|
||||
enum ContractStatus {
|
||||
UPLOADED // Just uploaded, waiting for processing
|
||||
PROCESSING // AI is analyzing
|
||||
COMPLETED // Everything done
|
||||
FAILED // Processing failed
|
||||
}
|
||||
|
||||
|
||||
0
prisma/user.prisma
Normal file
0
prisma/user.prisma
Normal file
44
proxy.ts
Normal file
44
proxy.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/middleware.ts
|
||||
// ─────────────────────────────────────────────────────
|
||||
// This is the BOUNCER of your entire app.
|
||||
// It runs BEFORE any page loads and checks if
|
||||
// the user is allowed to access that route.
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
|
||||
|
||||
// Define which routes REQUIRE login
|
||||
// Anyone hitting these without being logged in
|
||||
// gets automatically redirected to /sign-in
|
||||
const isProtectedRoute = createRouteMatcher([
|
||||
"/dashboard(.*)", // /dashboard and all sub-pages
|
||||
"/contracts(.*)", // /contracts and all sub-pages
|
||||
"/chat(.*)", // /chat and all sub-pages
|
||||
"/claims(.*)", // /claims and all sub-pages
|
||||
"/blockchain(.*)", // /blockchain and all sub-pages
|
||||
"/settings(.*)", // /settings and all sub-pages
|
||||
"/api/contracts(.*)", // Protect API routes too!
|
||||
"/api/chat(.*)",
|
||||
"/api/claims(.*)",
|
||||
]);
|
||||
|
||||
export default clerkMiddleware(async (auth, req) => {
|
||||
// If the route is protected, enforce authentication
|
||||
// auth.protect() will:
|
||||
// → Redirect to /sign-in if not logged in
|
||||
// → Do nothing if logged in
|
||||
if (isProtectedRoute(req)) {
|
||||
await auth.protect();
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
// Run middleware on all routes EXCEPT:
|
||||
// - Next.js internal files (_next)
|
||||
// - Static files (images, fonts, etc.)
|
||||
"/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
|
||||
// Always run on API routes
|
||||
"/(api|trpc)(.*)",
|
||||
],
|
||||
};
|
||||
BIN
public/Contrat_Credit_Immobilier_BTE.pdf
Normal file
BIN
public/Contrat_Credit_Immobilier_BTE.pdf
Normal file
Binary file not shown.
23
public/manifest.json
Normal file
23
public/manifest.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "LexiChain - AI Contract Management",
|
||||
"short_name": "LexiChain",
|
||||
"description": "Intelligent BFSI contract management with AI analysis",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#0066FF",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
6
scripts/package.json
Normal file
6
scripts/package.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "bfsi-scripts",
|
||||
"scripts": {
|
||||
"sync-users": "tsx scripts/sync-users.ts"
|
||||
}
|
||||
}
|
||||
63
scripts/sync-existing-user.ts
Normal file
63
scripts/sync-existing-user.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
// One-time script to sync existing Clerk user to database
|
||||
// Run this once: npx tsx scripts/sync-existing-user.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function syncExistingUser() {
|
||||
try {
|
||||
console.log("🔄 Starting one-time user sync...\n");
|
||||
|
||||
// Get all users from Clerk
|
||||
const clerk = await clerkClient();
|
||||
const users = await clerk.users.getUserList();
|
||||
|
||||
console.log(`📋 Found ${users.data.length} user(s) in Clerk\n`);
|
||||
|
||||
for (const user of users.data) {
|
||||
try {
|
||||
// Check if user already exists in database
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { clerkId: user.id },
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log(
|
||||
`⏭️ User already synced: ${user.emailAddresses[0]?.emailAddress}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create user in database
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
clerkId: user.id,
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
firstName: user.firstName || null,
|
||||
lastName: user.lastName || null,
|
||||
imageUrl: user.imageUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(
|
||||
`✅ Synced user: ${newUser.email} (Database ID: ${newUser.id})`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error syncing user ${user.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n✨ User sync completed!");
|
||||
console.log("🎉 You can now upload contracts!");
|
||||
} catch (error) {
|
||||
console.error("❌ Error in sync process:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
syncExistingUser();
|
||||
49
scripts/sync-users.ts
Normal file
49
scripts/sync-users.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Script to sync existing Clerk users to database
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
async function syncUsers() {
|
||||
try {
|
||||
console.log("🔄 Starting user sync...");
|
||||
|
||||
// Get all users from Clerk
|
||||
const clerk = await clerkClient();
|
||||
const users = await clerk.users.getUserList();
|
||||
|
||||
console.log(`📋 Found ${users.data.length} users in Clerk`);
|
||||
|
||||
for (const user of users.data) {
|
||||
try {
|
||||
await prisma.user.upsert({
|
||||
where: { clerkId: user.id },
|
||||
create: {
|
||||
clerkId: user.id,
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Synced user: ${user.emailAddresses[0]?.emailAddress}`);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error syncing user ${user.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("✨ User sync completed!");
|
||||
} catch (error) {
|
||||
console.error("❌ Error in sync process:", error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
syncUsers();
|
||||
50
setup-notifications.sh
Normal file
50
setup-notifications.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# 🔔 Notification System Setup Script
|
||||
# Run this script to set up the notification system in your project
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔔 Starting Notification System Setup..."
|
||||
echo ""
|
||||
|
||||
# Step 1: Create Prisma migration
|
||||
echo "📋 Step 1: Creating Prisma migration..."
|
||||
echo "Running: npx prisma migrate dev --name add_notifications"
|
||||
npx prisma migrate dev --name add_notifications
|
||||
|
||||
# Step 2: Generate Prisma client
|
||||
echo ""
|
||||
echo "🔄 Step 2: Generating Prisma Client..."
|
||||
echo "Running: npx prisma generate"
|
||||
npx prisma generate
|
||||
|
||||
# Step 3: Verify installation
|
||||
echo ""
|
||||
echo "✅ Step 3: Verifying installation..."
|
||||
|
||||
# Check if migration was successful
|
||||
if npm ls @prisma/client > /dev/null 2>&1; then
|
||||
echo "✅ Prisma Client found"
|
||||
else
|
||||
echo "❌ Prisma Client not found, installing..."
|
||||
npm install @prisma/client
|
||||
fi
|
||||
|
||||
# Step 4: Build TypeScript
|
||||
echo ""
|
||||
echo "🔨 Step 4: Building TypeScript..."
|
||||
npm run build
|
||||
|
||||
# Step 5: Success message
|
||||
echo ""
|
||||
echo "✅ Notification System Setup Complete!"
|
||||
echo ""
|
||||
echo "📚 Next Steps:"
|
||||
echo "1. Review NOTIFICATION_SYSTEM_SETUP.md for detailed documentation"
|
||||
echo "2. Check that the NotificationBar component appears in your dashboard"
|
||||
echo "3. Test the notification system by:"
|
||||
echo " - Uploading a contract (should see success notification)"
|
||||
echo " - Analyzing a contract (should see processing then success/error notification)"
|
||||
echo " - Checking the notification bar for deadline alerts"
|
||||
echo ""
|
||||
echo "🚀 The notification system is now active!"
|
||||
@@ -10,6 +10,10 @@ const config: Config = {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||
mono: ["var(--font-mono)", "monospace"],
|
||||
},
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
input: "hsl(var(--input))",
|
||||
|
||||
83
types/contract.types.ts
Normal file
83
types/contract.types.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/types/contract.types.ts
|
||||
|
||||
export type ContractType =
|
||||
| "INSURANCE_AUTO"
|
||||
| "INSURANCE_HOME"
|
||||
| "INSURANCE_HEALTH"
|
||||
| "INSURANCE_LIFE"
|
||||
| "LOAN"
|
||||
| "CREDIT_CARD"
|
||||
| "INVESTMENT"
|
||||
| "OTHER";
|
||||
|
||||
export type ContractStatus = "UPLOADED" | "PROCESSING" | "COMPLETED" | "FAILED";
|
||||
|
||||
export interface Contract {
|
||||
id: string;
|
||||
userId: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
|
||||
// AI-determined
|
||||
title: string | null;
|
||||
type: ContractType | null;
|
||||
provider: string | null;
|
||||
policyNumber: string | null;
|
||||
startDate: Date | null;
|
||||
endDate: Date | null;
|
||||
premium: number | null;
|
||||
|
||||
status: ContractStatus;
|
||||
|
||||
extractedText: string | null;
|
||||
summary: string | null;
|
||||
keyPoints: {
|
||||
guarantees?: string[];
|
||||
exclusions?: string[];
|
||||
franchise?: string;
|
||||
importantDates?: string[];
|
||||
} | null;
|
||||
|
||||
documentHash: string | null;
|
||||
txHash: string | null;
|
||||
ipfsUrl: string | null;
|
||||
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface UploadContractInput {
|
||||
file: File;
|
||||
}
|
||||
|
||||
export interface ContractFilters {
|
||||
type?: ContractType;
|
||||
status?: ContractStatus;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ContractStats {
|
||||
total: number;
|
||||
active: number;
|
||||
expired: number;
|
||||
expiringSoon: number;
|
||||
}
|
||||
|
||||
export interface AIAnalysisResult {
|
||||
title: string;
|
||||
type: ContractType;
|
||||
provider?: string;
|
||||
policyNumber?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
premium?: number;
|
||||
summary: string;
|
||||
keyPoints: {
|
||||
guarantees?: string[];
|
||||
exclusions?: string[];
|
||||
franchise?: string;
|
||||
importantDates?: string[];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user