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,
|
"rlig" 1,
|
||||||
"calt" 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 {
|
@layer utilities {
|
||||||
|
|||||||
@@ -1,24 +1,89 @@
|
|||||||
import type { Metadata } from "next";
|
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 "./globals.css";
|
||||||
import { Providers } from "./provider";
|
import { Providers } from "./provider";
|
||||||
|
|
||||||
const poppins = Poppins({
|
// Modern sans-serif font for body text
|
||||||
|
const inter = Inter({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
weight: ["400", "500", "600", "700", "800", "900"],
|
variable: "--font-inter",
|
||||||
variable: "--font-poppins",
|
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
weight: ["300", "400", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
// Monospace font for code and numbers
|
||||||
|
const jetbrainsMono = JetBrains_Mono({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
variable: "--font-geist-mono",
|
variable: "--font-mono",
|
||||||
display: "swap",
|
display: "swap",
|
||||||
|
weight: ["400", "500", "600", "700"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: {
|
||||||
description: "Generated by create next app",
|
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({
|
export default function RootLayout({
|
||||||
@@ -27,9 +92,18 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
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
|
<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>
|
<Providers>{children}</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
// src/components/providers.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { ClerkThemeProvider } from "./clerk-provider";
|
||||||
|
|
||||||
export function Providers({ children }: { children: ReactNode }) {
|
export function Providers({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -10,7 +13,7 @@ export function Providers({ children }: { children: ReactNode }) {
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
{children}
|
<ClerkThemeProvider>{children}</ClerkThemeProvider>
|
||||||
</ThemeProvider>
|
</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 { GripVertical } from "lucide-react";
|
||||||
import * as ResizablePrimitive from "react-resizable-panels"
|
import * as ResizablePrimitive from "react-resizable-panels";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
|
|
||||||
const ResizablePanel = ResizablePrimitive.Panel
|
const ResizablePanel = ResizablePrimitive.Panel;
|
||||||
|
|
||||||
const ResizableHandle = ({
|
const ResizableHandle = ({
|
||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean;
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
>
|
>
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
);
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
Check,
|
Check,
|
||||||
Zap,
|
Zap,
|
||||||
Link,
|
Link2,
|
||||||
FileText,
|
FileText,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Shield,
|
Shield,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Bell,
|
Bell,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { SignedIn, SignedOut } from "@clerk/nextjs";
|
||||||
|
|
||||||
// Ripple Effect Component
|
// Ripple Effect Component
|
||||||
function BackgroundRipple() {
|
function BackgroundRipple() {
|
||||||
@@ -407,7 +409,7 @@ export function Hero() {
|
|||||||
{ icon: Lock, label: "Bank-Level Security" },
|
{ icon: Lock, label: "Bank-Level Security" },
|
||||||
{ icon: Check, label: "GDPR Certified" },
|
{ icon: Check, label: "GDPR Certified" },
|
||||||
{ icon: Zap, label: "Real-Time AI" },
|
{ icon: Zap, label: "Real-Time AI" },
|
||||||
{ icon: Link, label: "Blockchain Verified" },
|
{ icon: Link2, label: "Blockchain Verified" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -544,8 +546,10 @@ export function Hero() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Primary CTA */}
|
{/* Primary CTA */}
|
||||||
<Button
|
<SignedOut>
|
||||||
className="
|
<Button
|
||||||
|
asChild
|
||||||
|
className="
|
||||||
group relative px-12 py-5
|
group relative px-12 py-5
|
||||||
text-lg md:text-xl font-semibold
|
text-lg md:text-xl font-semibold
|
||||||
text-white rounded-2xl
|
text-white rounded-2xl
|
||||||
@@ -560,10 +564,11 @@ export function Hero() {
|
|||||||
hover:shadow-xl hover:shadow-blue-500/40
|
hover:shadow-xl hover:shadow-blue-500/40
|
||||||
active:scale-[0.98]
|
active:scale-[0.98]
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{/* Glow background layer */}
|
<Link href="/sign-in">
|
||||||
<div
|
{/* Glow background layer */}
|
||||||
className="
|
<div
|
||||||
|
className="
|
||||||
absolute inset-0
|
absolute inset-0
|
||||||
opacity-0 group-hover:opacity-100
|
opacity-0 group-hover:opacity-100
|
||||||
transition-opacity duration-500
|
transition-opacity duration-500
|
||||||
@@ -573,31 +578,89 @@ export function Hero() {
|
|||||||
to-teal-400/20
|
to-teal-400/20
|
||||||
blur-xl
|
blur-xl
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Animated gradient shift layer */}
|
{/* Animated gradient shift layer */}
|
||||||
<div
|
<div
|
||||||
className="
|
className="
|
||||||
absolute inset-0
|
absolute inset-0
|
||||||
bg-[length:200%_200%]
|
bg-[length:200%_200%]
|
||||||
animate-gradient-shift
|
animate-gradient-shift
|
||||||
opacity-70
|
opacity-70
|
||||||
mix-blend-overlay
|
mix-blend-overlay
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="relative z-10 flex items-center gap-3">
|
<span className="relative z-10 flex items-center gap-3">
|
||||||
Get Started
|
Get Started
|
||||||
<Rocket
|
<Rocket
|
||||||
className="
|
className="
|
||||||
w-6 h-6
|
w-6 h-6
|
||||||
transition-transform duration-300
|
transition-transform duration-300
|
||||||
group-hover:translate-x-1
|
group-hover:translate-x-1
|
||||||
group-hover:-translate-y-1
|
group-hover:-translate-y-1
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Trust Indicators */}
|
{/* Trust Indicators */}
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ export function HowItWorks() {
|
|||||||
number: "02",
|
number: "02",
|
||||||
title: "AI Analysis",
|
title: "AI Analysis",
|
||||||
description:
|
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,
|
icon: Cpu,
|
||||||
gradient: "bg-gradient-to-br from-violet-500 to-purple-600",
|
gradient: "bg-gradient-to-br from-violet-500 to-purple-600",
|
||||||
glowColor: "rgba(139, 92, 246, 0.5)",
|
glowColor: "rgba(139, 92, 246, 0.5)",
|
||||||
|
|||||||
@@ -3,8 +3,16 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ModeToggle } from "@/components/ui/mode-toggle";
|
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 Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
SignedIn,
|
||||||
|
SignedOut,
|
||||||
|
SignInButton,
|
||||||
|
SignUpButton,
|
||||||
|
UserButton,
|
||||||
|
} from "@clerk/nextjs";
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: "Features", href: "#features" },
|
{ label: "Features", href: "#features" },
|
||||||
@@ -17,12 +25,6 @@ export function Navbar() {
|
|||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [activeLink, setActiveLink] = useState("");
|
const [activeLink, setActiveLink] = useState("");
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
setIsScrolled(window.scrollY > 20);
|
setIsScrolled(window.scrollY > 20);
|
||||||
@@ -46,26 +48,6 @@ export function Navbar() {
|
|||||||
setIsMobileMenuOpen(false);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
@@ -100,44 +82,15 @@ export function Navbar() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative flex items-center justify-center">
|
<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" />
|
||||||
<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 */}
|
|
||||||
<Image
|
<Image
|
||||||
src="/LexiChain.png"
|
src="/LexiChain.png"
|
||||||
alt="LexiChain Logo"
|
alt="LexiChain Logo"
|
||||||
width={34}
|
width={34}
|
||||||
height={34}
|
height={34}
|
||||||
className="
|
className="relative z-10 w-8 h-8 object-contain transition-all duration-300 ease-out group-hover:scale-110"
|
||||||
relative z-10
|
|
||||||
w-8 h-8 object-contain
|
|
||||||
transition-all duration-300 ease-out
|
|
||||||
group-hover:scale-110
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span
|
|
||||||
className="
|
|
||||||
text-lg font-semibold
|
|
||||||
gradient-text
|
|
||||||
sm:hidden
|
|
||||||
"
|
|
||||||
>
|
|
||||||
LC
|
|
||||||
</span>
|
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
@@ -163,46 +116,45 @@ export function Navbar() {
|
|||||||
|
|
||||||
{/* Right Section */}
|
{/* Right Section */}
|
||||||
<div className="flex items-center gap-2 md:gap-3">
|
<div className="flex items-center gap-2 md:gap-3">
|
||||||
{/* Theme Toggle */}
|
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
|
{/* Global Clerk context usage */}
|
||||||
|
<SignedOut>
|
||||||
|
<SignInButton mode="modal">
|
||||||
|
<Button variant="outline" className="hidden md:flex">
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</SignInButton>
|
||||||
|
|
||||||
{/* Sign In - Desktop */}
|
<SignUpButton mode="modal">
|
||||||
<Button
|
<Button className="hidden md:flex btn-gradient">
|
||||||
variant="outline"
|
Get Started
|
||||||
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"
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
>
|
</Button>
|
||||||
Sign In
|
</SignUpButton>
|
||||||
</Button>
|
</SignedOut>
|
||||||
|
<SignedIn>
|
||||||
|
<Link href="/dashboard" className="hidden md:inline-block">
|
||||||
|
<Button variant="outline">Dashboard</Button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{/* Get Started - Desktop */}
|
<UserButton afterSignOutUrl="/" />
|
||||||
<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">
|
</SignedIn>
|
||||||
Get Started
|
|
||||||
<ArrowRight className="w-4 h-4 transition-transform duration-300 group-hover:translate-x-1" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
className="lg:hidden p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors duration-200"
|
className="lg:hidden"
|
||||||
aria-label="Toggle menu"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
>
|
>
|
||||||
<div className="relative w-6 h-6">
|
<div className="relative w-6 h-6">
|
||||||
<span
|
<span
|
||||||
className={`absolute left-0 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
|
className={`absolute left-0 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "top-3 rotate-45" : "top-1"}`}
|
||||||
isMobileMenuOpen ? "top-3 rotate-45" : "top-1"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`absolute left-0 top-3 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
|
className={`absolute left-0 top-3 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "opacity-0" : "opacity-100"}`}
|
||||||
isMobileMenuOpen ? "opacity-0" : "opacity-100"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`absolute left-0 w-6 h-0.5 bg-slate-700 dark:bg-slate-300 transition-all duration-300 ${
|
className={`absolute left-0 w-6 h-0.5 bg-current transition-all ${isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"}`}
|
||||||
isMobileMenuOpen ? "top-3 -rotate-45" : "top-5"
|
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -214,27 +166,20 @@ export function Navbar() {
|
|||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
<div
|
<div
|
||||||
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${
|
className={`fixed inset-0 z-40 lg:hidden transition-all duration-500 ${isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"}`}
|
||||||
isMobileMenuOpen ? "opacity-100 visible" : "opacity-0 invisible"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Backdrop */}
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Menu Panel */}
|
|
||||||
<div
|
<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 ${
|
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"}`}
|
||||||
isMobileMenuOpen ? "translate-x-0" : "translate-x-full"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="p-6 pt-20">
|
<div className="p-6 pt-20">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsMobileMenuOpen(false)}
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className="absolute top-6 right-6 p-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
className="absolute top-6 right-6"
|
||||||
aria-label="Close menu"
|
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
>
|
>
|
||||||
@@ -242,36 +187,54 @@ export function Navbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{navLinks.map((link, index) => (
|
{navLinks.map((link) => (
|
||||||
<a
|
<a
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
onClick={(e) => handleLinkClick(e, 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"
|
className="px-4 py-3 text-lg font-medium"
|
||||||
style={{
|
|
||||||
animationDelay: `${index * 0.1}s`,
|
|
||||||
animation: isMobileMenuOpen
|
|
||||||
? "slide-up 0.4s ease-out forwards"
|
|
||||||
: "none",
|
|
||||||
opacity: isMobileMenuOpen ? 1 : 0,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col gap-3">
|
<div className="mt-4 border-t border-slate-200/70 dark:border-slate-700/70 pt-5 space-y-3">
|
||||||
<Button
|
<SignedOut>
|
||||||
variant="outline"
|
<Link
|
||||||
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"
|
href="/sign-in"
|
||||||
>
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
Sign In
|
>
|
||||||
</Button>
|
<Button variant="outline" className="w-full">
|
||||||
<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">
|
Sign In
|
||||||
Get Started
|
</Button>
|
||||||
<ArrowRight className="w-4 h-4" />
|
</Link>
|
||||||
</Button>
|
<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>
|
</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"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@clerk/nextjs": "^6.37.4",
|
||||||
|
"@clerk/themes": "^2.4.53",
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@prisma/client": "^6.19.2",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
@@ -37,23 +41,29 @@
|
|||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"@uploadthing/react": "^7.3.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.564.0",
|
"lucide-react": "^0.564.0",
|
||||||
"motion": "^12.34.0",
|
"motion": "^12.34.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"prisma": "^6.19.2",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-day-picker": "^9.13.2",
|
"react-day-picker": "^9.13.2",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-resizable-panels": "^4.6.4",
|
"react-resizable-panels": "^4.6.4",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
"svix": "^1.85.0",
|
||||||
"tailwind-merge": "^3.4.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"uploadthing": "^7.7.4",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
@@ -62,8 +72,8 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"eslint": "^9",
|
"eslint": "^10.0.1",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "^16.1.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
|
||||||
|
mono: ["var(--font-mono)", "monospace"],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
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