Compare commits
16 Commits
3ba8542e4f
...
bed42c7329
| Author | SHA1 | Date |
|---|---|---|
|
|
bed42c7329 | |
|
|
0762306bf3 | |
|
|
080cb60d71 | |
|
|
b8a6a52417 | |
|
|
9f7aa99320 | |
|
|
1a24faa9db | |
|
|
9b045c7b97 | |
|
|
7244349fe7 | |
|
|
61235b163d | |
|
|
d71e2d348c | |
|
|
eb1d853327 | |
|
|
7ee2cb3368 | |
|
|
c6f81a6686 | |
|
|
bf2361050f | |
|
|
242e33f7b9 | |
|
|
6ebe96bbf4 |
|
|
@ -13,3 +13,4 @@ logs
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
nul
|
||||||
|
|
|
||||||
2
app.vue
2
app.vue
|
|
@ -1,5 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtPwaManifest />
|
<NuxtPwaManifest />
|
||||||
|
<NuxtLayout>
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
<GlobalToast />
|
<GlobalToast />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
{{ expense.DisplayPrice || expense.Price }}
|
{{ expense.DisplayPrice || expense.Price }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
|
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
|
||||||
<span class="text-caption text-grey-darken-1">
|
<span class="text-caption text-grey-darken-3">
|
||||||
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -64,12 +64,12 @@
|
||||||
|
|
||||||
<!-- Multiple receipts indicator -->
|
<!-- Multiple receipts indicator -->
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="expense.Receipt.length > 1"
|
|
||||||
size="x-small"
|
size="x-small"
|
||||||
color="primary"
|
variant="flat"
|
||||||
class="receipt-count-chip"
|
:color="getCategoryColor(expense.Category)"
|
||||||
|
class="text-caption text-grey-darken-3"
|
||||||
>
|
>
|
||||||
+{{ expense.Receipt.length - 1 }}
|
{{ expense.Category || 'Other' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,7 +215,7 @@
|
||||||
<span class="text-caption">Delete</span>
|
<span class="text-caption">Delete</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="12">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
|
@ -848,7 +848,7 @@ const handleFormSubmit = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveInterest = async (isAutoSave = false) => {
|
const saveInterest = async (isAutoSave = false, closeAfterSave = false) => {
|
||||||
if (interest.value) {
|
if (interest.value) {
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -871,7 +871,11 @@ const saveInterest = async (isAutoSave = false) => {
|
||||||
if (!isAutoSave) {
|
if (!isAutoSave) {
|
||||||
toast.success("Interest saved successfully!");
|
toast.success("Interest saved successfully!");
|
||||||
emit("save", interest.value);
|
emit("save", interest.value);
|
||||||
|
|
||||||
|
// Only close if explicitly requested
|
||||||
|
if (closeAfterSave) {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For auto-save, just emit save to refresh parent
|
// For auto-save, just emit save to refresh parent
|
||||||
emit("save", interest.value);
|
emit("save", interest.value);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,182 @@
|
||||||
|
# 404 and Session Expiration Fixes
|
||||||
|
|
||||||
|
## Issues Addressed
|
||||||
|
|
||||||
|
1. **404 Error on Expenses Page** - The expenses page was returning a 404 error
|
||||||
|
2. **Session Expiration After 404** - Users were getting logged out after encountering the 404 error
|
||||||
|
3. **Immediate Session Expiration** - Users were getting logged out immediately after logging in
|
||||||
|
4. **External Service 401 Errors** - 401 errors from external services (CMS, database) were logging users out
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### 404 Error Cause
|
||||||
|
- The expenses page was missing the authorization middleware configuration
|
||||||
|
- The dashboard layout referenced in the page metadata didn't exist
|
||||||
|
- Nuxt wasn't properly configured to use layouts
|
||||||
|
|
||||||
|
### Session Expiration Cause
|
||||||
|
- The authentication middleware was incorrectly clearing the session cache on ALL errors (including 404s)
|
||||||
|
- This caused a valid session to be invalidated when encountering any page error
|
||||||
|
|
||||||
|
### Immediate Logout Cause
|
||||||
|
- The authorization middleware was making its own API call, bypassing the session cache
|
||||||
|
- The auth refresh plugin's 2-minute periodic validation was conflicting with the 3-minute session cache
|
||||||
|
- Multiple concurrent session checks were causing race conditions
|
||||||
|
|
||||||
|
### External Service 401 Cause
|
||||||
|
- The auth error handler was treating ANY 401 error as a session expiration
|
||||||
|
- When the CMS (`cms.portnimara.dev`) returned 401, it triggered a logout
|
||||||
|
- The handler didn't distinguish between auth API errors and external service errors
|
||||||
|
|
||||||
|
## Fixes Implemented
|
||||||
|
|
||||||
|
### 1. Fixed Expenses Page Metadata
|
||||||
|
**File**: `pages/dashboard/expenses.vue`
|
||||||
|
|
||||||
|
Added proper middleware configuration:
|
||||||
|
```javascript
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['authentication', 'authorization'],
|
||||||
|
layout: 'dashboard',
|
||||||
|
roles: ['sales', 'admin']
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures:
|
||||||
|
- Authentication is checked first
|
||||||
|
- Authorization checks for sales/admin roles
|
||||||
|
- Proper layout is applied
|
||||||
|
|
||||||
|
### 2. Fixed Authentication Middleware
|
||||||
|
**File**: `middleware/authentication.ts`
|
||||||
|
|
||||||
|
Updated error handling to only clear cache on actual auth errors:
|
||||||
|
```javascript
|
||||||
|
onResponseError({ response }) {
|
||||||
|
// Clear cache only on actual auth errors, not 404s or other errors
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.log('[MIDDLEWARE] Unauthorized error detected, clearing cache')
|
||||||
|
sessionManager.clearCache();
|
||||||
|
delete nuxtApp.payload.data?.authState;
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
console.log('[MIDDLEWARE] Forbidden error detected, partial cache clear')
|
||||||
|
// Don't clear cache on 403 as user is authenticated but lacks permissions
|
||||||
|
}
|
||||||
|
// Ignore 404s and other errors - they're not authentication issues
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Enabled Layout Support
|
||||||
|
**File**: `app.vue`
|
||||||
|
|
||||||
|
Updated to support layouts:
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<NuxtPwaManifest />
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
<GlobalToast />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Created Dashboard Layout
|
||||||
|
**File**: `layouts/dashboard.vue`
|
||||||
|
|
||||||
|
Created a full dashboard layout with:
|
||||||
|
- Navigation drawer with role-based menu items
|
||||||
|
- App bar showing user info and role badges
|
||||||
|
- Proper logout functionality
|
||||||
|
- Responsive design with rail mode
|
||||||
|
- Safe auth state access to prevent initialization errors
|
||||||
|
|
||||||
|
### 5. Fixed Authorization Middleware
|
||||||
|
**File**: `middleware/authorization.ts`
|
||||||
|
|
||||||
|
Updated to use cached auth state instead of making API calls:
|
||||||
|
```javascript
|
||||||
|
// Get auth state from authentication middleware (already cached)
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const authState = nuxtApp.payload?.data?.authState;
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents:
|
||||||
|
- Duplicate API calls
|
||||||
|
- Race conditions between middlewares
|
||||||
|
- Session cache conflicts
|
||||||
|
|
||||||
|
### 6. Adjusted Auth Refresh Plugin
|
||||||
|
**File**: `plugins/01.auth-refresh.client.ts`
|
||||||
|
|
||||||
|
- Changed periodic validation from 2 to 5 minutes to avoid conflicts with 3-minute cache
|
||||||
|
- Added failure counting - only logs out after 3 consecutive failures
|
||||||
|
- Increased random offset to prevent thundering herd
|
||||||
|
|
||||||
|
### 7. Fixed Auth Error Handler
|
||||||
|
**File**: `plugins/02.auth-error-handler.client.ts`
|
||||||
|
|
||||||
|
Updated to only handle 401/403 errors from application endpoints:
|
||||||
|
```javascript
|
||||||
|
// Only handle authentication errors from our own API endpoints
|
||||||
|
const isAuthEndpoint = response.url && (
|
||||||
|
response.url.includes('/api/auth/') ||
|
||||||
|
response.url.includes('/api/') && !response.url.includes('cms.portnimara.dev') && !response.url.includes('database.portnimara.com')
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle authentication errors (401, 403) only from our API
|
||||||
|
if ((response.status === 401 || response.status === 403) && isAuthEndpoint) {
|
||||||
|
// Clear auth and redirect
|
||||||
|
} else if (response.status === 401 && !isAuthEndpoint) {
|
||||||
|
console.log('[AUTH_ERROR_HANDLER] Ignoring 401 from external service:', response.url)
|
||||||
|
// Don't clear auth for external service 401s
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This prevents external service authentication errors from logging users out.
|
||||||
|
|
||||||
|
## Expected Results
|
||||||
|
|
||||||
|
1. **Expenses page should now load properly** for users with sales or admin roles
|
||||||
|
2. **404 errors won't cause session expiration** - only actual authentication failures (401) will clear the session
|
||||||
|
3. **Better error handling** - 403 errors (insufficient permissions) will redirect to dashboard with a message instead of logging out
|
||||||
|
4. **Consistent layout** across all dashboard pages
|
||||||
|
5. **No immediate logout** - Session checks are properly coordinated and cached
|
||||||
|
6. **Stable session management** - No conflicts between different auth checking mechanisms
|
||||||
|
7. **External service errors ignored** - 401 errors from CMS or database won't log users out
|
||||||
|
|
||||||
|
## Testing Steps
|
||||||
|
|
||||||
|
1. Log in with a user that has sales or admin role
|
||||||
|
2. Navigate to `/dashboard/expenses`
|
||||||
|
3. Verify the page loads without 404
|
||||||
|
4. If you don't have the required role, you should be redirected to dashboard with an error message (not logged out)
|
||||||
|
5. Try navigating to a non-existent page - you should get a 404 but remain logged in
|
||||||
|
|
||||||
|
## Additional Improvements
|
||||||
|
|
||||||
|
- The authorization middleware now stores error messages that are displayed via toast
|
||||||
|
- The authorization middleware uses cached auth state instead of making API calls
|
||||||
|
- The dashboard layout shows the current user and their role with safe access patterns
|
||||||
|
- Navigation menu dynamically shows/hides items based on user roles
|
||||||
|
- Session validation continues to work with the 3-minute cache + jitter to prevent race conditions
|
||||||
|
- Auth refresh plugin runs validation every 5 minutes to avoid cache conflicts
|
||||||
|
- Multiple failure tolerance prevents transient issues from logging users out
|
||||||
|
- Auth error handler differentiates between app and external service errors
|
||||||
|
|
||||||
|
## Timing Configuration Summary
|
||||||
|
|
||||||
|
- **Session Cache**: 3 minutes (with 0-10 second jitter)
|
||||||
|
- **Auth Refresh Validation**: Every 5 minutes (with 0-10 second offset)
|
||||||
|
- **Token Refresh**: 5 minutes before token expiry
|
||||||
|
- **Failure Tolerance**: 3 consecutive failures before logout
|
||||||
|
|
||||||
|
This configuration ensures no timing conflicts between different auth mechanisms.
|
||||||
|
|
||||||
|
## External Service Integration
|
||||||
|
|
||||||
|
The auth error handler now properly handles errors from external services:
|
||||||
|
- **CMS errors** (cms.portnimara.dev) - 401 errors are logged but don't trigger logout
|
||||||
|
- **Database errors** (database.portnimara.com) - 401 errors are logged but don't trigger logout
|
||||||
|
- **App API errors** (/api/*) - 401/403 errors still trigger logout as expected
|
||||||
|
|
||||||
|
This allows the app to gracefully handle authentication failures with integrated services without disrupting the user's main session.
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
# Authentication Session Timeout Fix - Deployment Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document provides step-by-step instructions for deploying the authentication session timeout fixes that resolve the 2-minute logout issue.
|
||||||
|
|
||||||
|
## Problem Summary
|
||||||
|
|
||||||
|
Users were experiencing unexpected logouts after exactly 2 minutes when navigating between pages. This was caused by:
|
||||||
|
|
||||||
|
1. **Timing Race Condition**: Authentication middleware cache expiry (2 minutes) and auth refresh plugin periodic validation (2 minutes) occurring simultaneously
|
||||||
|
2. **No Request Deduplication**: Multiple concurrent session checks causing conflicts
|
||||||
|
3. **Insufficient Error Handling**: Network errors triggering immediate logouts
|
||||||
|
4. **No Grace Periods**: Transient issues causing permanent session loss
|
||||||
|
|
||||||
|
## Solution Overview
|
||||||
|
|
||||||
|
### Core Changes
|
||||||
|
|
||||||
|
1. **Session Manager Utility** (`server/utils/session-manager.ts`)
|
||||||
|
- Centralized session management with request deduplication
|
||||||
|
- Promise caching for in-flight requests
|
||||||
|
- Network error grace periods
|
||||||
|
- Comprehensive logging and statistics
|
||||||
|
|
||||||
|
2. **Authentication Middleware** (`middleware/authentication.ts`)
|
||||||
|
- Changed cache expiry from 2 to 3 minutes with jitter
|
||||||
|
- Integrated SessionManager for deduplication
|
||||||
|
- Enhanced error handling and user feedback
|
||||||
|
|
||||||
|
3. **Auth Refresh Plugin** (`plugins/01.auth-refresh.client.ts`)
|
||||||
|
- Added random offset to prevent simultaneous validation
|
||||||
|
- Improved concurrent validation prevention
|
||||||
|
- Better error handling for network issues
|
||||||
|
|
||||||
|
4. **Session API** (`server/api/auth/session.ts`)
|
||||||
|
- Enhanced logging with request IDs
|
||||||
|
- Detailed error categorization
|
||||||
|
- Performance timing measurements
|
||||||
|
|
||||||
|
5. **Keycloak Client** (`server/utils/keycloak-client.ts`)
|
||||||
|
- Better error type distinction
|
||||||
|
- Increased retry attempts for token refresh
|
||||||
|
- Improved timeout handling
|
||||||
|
|
||||||
|
6. **Refresh API** (`server/api/auth/refresh.ts`)
|
||||||
|
- Enhanced error handling with request IDs
|
||||||
|
- Grace period support for transient failures
|
||||||
|
- Selective session clearing based on error type
|
||||||
|
|
||||||
|
## Pre-deployment Checklist
|
||||||
|
|
||||||
|
- [ ] **Code Review**: All changes reviewed and approved
|
||||||
|
- [ ] **Environment Variables**: Verify all required environment variables are set
|
||||||
|
- [ ] **Dependencies**: Confirm no new dependencies are required
|
||||||
|
- [ ] **Backup**: Create backup of current production code
|
||||||
|
- [ ] **Monitoring**: Ensure authentication logs are being captured
|
||||||
|
- [ ] **Testing**: Verify fixes work in staging environment (if available)
|
||||||
|
|
||||||
|
## Deployment Steps
|
||||||
|
|
||||||
|
### Step 1: Deploy Session Manager Utility
|
||||||
|
|
||||||
|
1. Deploy `server/utils/session-manager.ts`
|
||||||
|
2. Verify no TypeScript compilation errors
|
||||||
|
3. Check server logs for any startup issues
|
||||||
|
|
||||||
|
### Step 2: Update Authentication Middleware
|
||||||
|
|
||||||
|
1. Deploy updated `middleware/authentication.ts`
|
||||||
|
2. Monitor for any middleware errors in logs
|
||||||
|
3. Verify new timing configuration is active
|
||||||
|
|
||||||
|
### Step 3: Update Auth Refresh Plugin
|
||||||
|
|
||||||
|
1. Deploy updated `plugins/01.auth-refresh.client.ts`
|
||||||
|
2. Check browser console for any client-side errors
|
||||||
|
3. Verify random offset is working (check logs)
|
||||||
|
|
||||||
|
### Step 4: Update Session API
|
||||||
|
|
||||||
|
1. Deploy updated `server/api/auth/session.ts`
|
||||||
|
2. Monitor API endpoint logs for request IDs
|
||||||
|
3. Verify enhanced error messages are working
|
||||||
|
|
||||||
|
### Step 5: Update Keycloak Client
|
||||||
|
|
||||||
|
1. Deploy updated `server/utils/keycloak-client.ts`
|
||||||
|
2. Check for any Keycloak communication errors
|
||||||
|
3. Verify retry logic is functioning
|
||||||
|
|
||||||
|
### Step 6: Update Refresh API
|
||||||
|
|
||||||
|
1. Deploy updated `server/api/auth/refresh.ts`
|
||||||
|
2. Monitor token refresh operations
|
||||||
|
3. Verify graceful error handling
|
||||||
|
|
||||||
|
## Post-deployment Verification
|
||||||
|
|
||||||
|
### Immediate Verification (0-5 minutes)
|
||||||
|
|
||||||
|
1. **No Deployment Errors**
|
||||||
|
```bash
|
||||||
|
# Check server logs
|
||||||
|
tail -f /var/log/application.log | grep -E "(ERROR|FATAL)"
|
||||||
|
|
||||||
|
# Check for any 500 errors
|
||||||
|
curl -I https://your-domain.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Login Flow Test**
|
||||||
|
- Navigate to login page
|
||||||
|
- Complete authentication
|
||||||
|
- Verify successful redirect to dashboard
|
||||||
|
|
||||||
|
3. **Session API Test**
|
||||||
|
```bash
|
||||||
|
# Test session endpoint
|
||||||
|
curl -X GET https://your-domain.com/api/auth/session \
|
||||||
|
-H "Cookie: nuxt-oidc-auth=<session-cookie>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Short-term Verification (5-15 minutes)
|
||||||
|
|
||||||
|
1. **Navigation Test**
|
||||||
|
- Stay logged in for 5+ minutes
|
||||||
|
- Navigate between different pages
|
||||||
|
- Verify no unexpected logouts
|
||||||
|
|
||||||
|
2. **Log Analysis**
|
||||||
|
```bash
|
||||||
|
# Check for new session manager logs
|
||||||
|
grep "SESSION_MANAGER" /var/log/application.log
|
||||||
|
|
||||||
|
# Verify timing desynchronization
|
||||||
|
grep "Using cached session" /var/log/application.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Long-term Verification (15+ minutes)
|
||||||
|
|
||||||
|
1. **2-Minute Boundary Test**
|
||||||
|
- Stay logged in for exactly 2 minutes
|
||||||
|
- Navigate to a new page
|
||||||
|
- Verify user remains authenticated
|
||||||
|
|
||||||
|
2. **3-Minute Cache Test**
|
||||||
|
- Stay on same page for 3+ minutes
|
||||||
|
- Navigate to new page
|
||||||
|
- Verify session is refreshed, not lost
|
||||||
|
|
||||||
|
3. **Network Error Simulation**
|
||||||
|
- Temporarily block network access
|
||||||
|
- Verify graceful degradation
|
||||||
|
- Restore network and verify recovery
|
||||||
|
|
||||||
|
## Monitoring and Alerts
|
||||||
|
|
||||||
|
### Key Metrics to Monitor
|
||||||
|
|
||||||
|
1. **Authentication Errors**
|
||||||
|
```bash
|
||||||
|
# Monitor auth failure rate
|
||||||
|
grep -c "AUTH_ERROR" /var/log/application.log
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Session Manager Performance**
|
||||||
|
```bash
|
||||||
|
# Check session check durations
|
||||||
|
grep "Session check completed" /var/log/application.log
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Cache Hit Rate**
|
||||||
|
```bash
|
||||||
|
# Monitor cache effectiveness
|
||||||
|
grep "Using cached session" /var/log/application.log | wc -l
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alert Thresholds
|
||||||
|
|
||||||
|
- **Auth Error Rate**: > 5% of total auth checks
|
||||||
|
- **Session Check Duration**: > 2 seconds average
|
||||||
|
- **Cache Miss Rate**: > 80% (indicates caching issues)
|
||||||
|
|
||||||
|
## Rollback Procedures
|
||||||
|
|
||||||
|
### Immediate Rollback (if critical issues)
|
||||||
|
|
||||||
|
1. **Stop Application**
|
||||||
|
```bash
|
||||||
|
systemctl stop your-application
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Restore Previous Code**
|
||||||
|
```bash
|
||||||
|
git checkout previous-stable-tag
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Restart Application**
|
||||||
|
```bash
|
||||||
|
systemctl start your-application
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Verify Rollback**
|
||||||
|
- Test login functionality
|
||||||
|
- Check error logs
|
||||||
|
- Verify user sessions work
|
||||||
|
|
||||||
|
### Partial Rollback (if specific component issues)
|
||||||
|
|
||||||
|
1. **Identify Problem Component**
|
||||||
|
- Check which specific file is causing issues
|
||||||
|
- Review recent error logs
|
||||||
|
|
||||||
|
2. **Rollback Specific Files**
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 -- middleware/authentication.ts
|
||||||
|
# or
|
||||||
|
git checkout HEAD~1 -- server/utils/session-manager.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Rebuild and Test**
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
systemctl restart your-application
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
1. **Users Still Getting Logged Out at 2 Minutes**
|
||||||
|
- Check if SessionManager is being used
|
||||||
|
- Verify cache expiry changes are active
|
||||||
|
- Look for timing synchronization issues
|
||||||
|
|
||||||
|
2. **Session Check Errors**
|
||||||
|
- Check network connectivity to Keycloak
|
||||||
|
- Verify environment variables are set
|
||||||
|
- Check Keycloak circuit breaker status
|
||||||
|
|
||||||
|
3. **Performance Issues**
|
||||||
|
- Monitor session check durations
|
||||||
|
- Check cache hit rates
|
||||||
|
- Verify request deduplication is working
|
||||||
|
|
||||||
|
### Debug Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check session manager cache stats
|
||||||
|
curl https://your-domain.com/api/debug/session-cache-stats
|
||||||
|
|
||||||
|
# Monitor real-time auth logs
|
||||||
|
tail -f /var/log/application.log | grep -E "(SESSION|AUTH_REFRESH|MIDDLEWARE)"
|
||||||
|
|
||||||
|
# Check Keycloak connectivity
|
||||||
|
curl https://your-domain.com/api/debug/test-keycloak-connectivity
|
||||||
|
```
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
The deployment is considered successful when:
|
||||||
|
|
||||||
|
1. **No 2-Minute Logouts**: Users can navigate freely after 2 minutes
|
||||||
|
2. **Improved Error Handling**: Network issues don't cause immediate logouts
|
||||||
|
3. **Better Performance**: Session checks complete faster due to caching
|
||||||
|
4. **Enhanced Logging**: Detailed logs help with debugging future issues
|
||||||
|
5. **Graceful Degradation**: System handles transient failures elegantly
|
||||||
|
|
||||||
|
## Contact Information
|
||||||
|
|
||||||
|
For issues or questions regarding this deployment:
|
||||||
|
|
||||||
|
- **Technical Lead**: [Your Name]
|
||||||
|
- **Emergency Contact**: [Emergency Number]
|
||||||
|
- **Documentation**: This file and related docs in `/docs/` directory
|
||||||
|
|
||||||
|
## Appendix
|
||||||
|
|
||||||
|
### Environment Variables Required
|
||||||
|
|
||||||
|
```env
|
||||||
|
KEYCLOAK_CLIENT_SECRET=your-secret-key
|
||||||
|
COOKIE_DOMAIN=.portnimara.dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Examples
|
||||||
|
|
||||||
|
Successful session check:
|
||||||
|
```
|
||||||
|
[SESSION_MANAGER:abc123] Session check completed: {"authenticated":true,"reason":null,"fromCache":false}
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache hit:
|
||||||
|
```
|
||||||
|
[SESSION_MANAGER:def456] Using cached session (age: 45 seconds)
|
||||||
|
```
|
||||||
|
|
||||||
|
Network error with grace period:
|
||||||
|
```
|
||||||
|
[SESSION_MANAGER:ghi789] Using cached result due to network error
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Benchmarks
|
||||||
|
|
||||||
|
- **Session Check Duration**: < 500ms average
|
||||||
|
- **Cache Hit Rate**: > 70%
|
||||||
|
- **Authentication Success Rate**: > 99%
|
||||||
|
- **Network Error Recovery**: < 5 seconds
|
||||||
|
|
@ -0,0 +1,321 @@
|
||||||
|
<template>
|
||||||
|
<v-app>
|
||||||
|
<v-navigation-drawer
|
||||||
|
v-model="drawer"
|
||||||
|
:rail="rail"
|
||||||
|
permanent
|
||||||
|
color="white"
|
||||||
|
class="elevation-2"
|
||||||
|
>
|
||||||
|
<!-- Logo and Title -->
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
class="px-3 py-3 cursor-pointer"
|
||||||
|
@click="rail = !rail"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar size="40" class="me-3">
|
||||||
|
<v-img src="/Port Nimara New Logo-Circular Frame.png" alt="Port Nimara" />
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title v-if="!rail" class="text-subtitle-1 font-weight-medium">
|
||||||
|
Port Nimara CRM
|
||||||
|
</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-divider></v-divider>
|
||||||
|
|
||||||
|
<!-- Navigation Items -->
|
||||||
|
<v-list density="compact" nav>
|
||||||
|
<v-list-item
|
||||||
|
v-for="item in navigationItems"
|
||||||
|
:key="item.to"
|
||||||
|
:prepend-icon="item.icon"
|
||||||
|
:title="item.label"
|
||||||
|
:value="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
color="primary"
|
||||||
|
rounded="xl"
|
||||||
|
class="mx-1"
|
||||||
|
></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-divider></v-divider>
|
||||||
|
|
||||||
|
<!-- User Info Section -->
|
||||||
|
<div class="pa-2">
|
||||||
|
<v-list v-if="authState?.user">
|
||||||
|
<v-list-item
|
||||||
|
:prepend-avatar="`https://ui-avatars.com/api/?name=${encodeURIComponent(authState.user.name || authState.user.email)}&background=387bca&color=fff`"
|
||||||
|
:title="authState.user.name || authState.user.email"
|
||||||
|
:subtitle="authState.user.email"
|
||||||
|
class="px-2"
|
||||||
|
>
|
||||||
|
<template v-slot:append v-if="!rail && authState?.groups?.length">
|
||||||
|
<div>
|
||||||
|
<v-chip
|
||||||
|
v-if="authState.groups.includes('admin')"
|
||||||
|
size="small"
|
||||||
|
color="orange"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-else-if="authState.groups.includes('sales')"
|
||||||
|
size="small"
|
||||||
|
color="green"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Sales
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
@click="handleLogout"
|
||||||
|
prepend-icon="mdi-logout"
|
||||||
|
title="Logout"
|
||||||
|
class="px-2 mt-1"
|
||||||
|
base-color="error"
|
||||||
|
rounded="xl"
|
||||||
|
></v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-navigation-drawer>
|
||||||
|
|
||||||
|
<v-app-bar
|
||||||
|
flat
|
||||||
|
color="white"
|
||||||
|
class="border-b"
|
||||||
|
>
|
||||||
|
<v-app-bar-nav-icon
|
||||||
|
@click="drawer = !drawer"
|
||||||
|
class="d-lg-none"
|
||||||
|
></v-app-bar-nav-icon>
|
||||||
|
|
||||||
|
<v-toolbar-title class="text-h6">
|
||||||
|
{{ pageTitle }}
|
||||||
|
</v-toolbar-title>
|
||||||
|
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
</v-app-bar>
|
||||||
|
|
||||||
|
<v-main class="bg-grey-lighten-4">
|
||||||
|
<slot />
|
||||||
|
</v-main>
|
||||||
|
</v-app>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useDisplay } from 'vuetify';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const nuxtApp = useNuxtApp();
|
||||||
|
const { mdAndDown } = useDisplay();
|
||||||
|
|
||||||
|
// Sidebar state
|
||||||
|
const drawer = ref(true);
|
||||||
|
const rail = ref(false);
|
||||||
|
|
||||||
|
// Get auth state - with fallback to prevent errors
|
||||||
|
const authState = computed(() => {
|
||||||
|
const data = nuxtApp.payload?.data?.authState;
|
||||||
|
// Only return data if it's properly initialized
|
||||||
|
if (data && data.authenticated !== undefined) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Page title based on current route
|
||||||
|
const pageTitle = computed(() => {
|
||||||
|
const routeName = route.name as string;
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
'dashboard': 'Dashboard',
|
||||||
|
'dashboard-index': 'Dashboard',
|
||||||
|
'dashboard-expenses': 'Expense Tracking',
|
||||||
|
'dashboard-interest-list': 'Interest List',
|
||||||
|
'dashboard-berth-list': 'Berth List',
|
||||||
|
'dashboard-interest-status': 'Interest Status',
|
||||||
|
'dashboard-interest-emails': 'Interest Emails',
|
||||||
|
'dashboard-interest-berth-list': 'Interest Berth List',
|
||||||
|
'dashboard-interest-berth-status': 'Berth Status',
|
||||||
|
'dashboard-interest-analytics': 'Analytics',
|
||||||
|
'dashboard-file-browser': 'File Browser',
|
||||||
|
'dashboard-admin': 'Admin Console',
|
||||||
|
'dashboard-admin-index': 'Admin Console',
|
||||||
|
'dashboard-admin-audit-logs': 'Audit Logs',
|
||||||
|
'dashboard-admin-system-logs': 'System Logs',
|
||||||
|
'dashboard-admin-duplicates': 'Duplicate Management',
|
||||||
|
'dashboard-sidebar-demo': 'Sidebar Demo',
|
||||||
|
};
|
||||||
|
|
||||||
|
return pageTitles[routeName] || 'Dashboard';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigation items based on user role
|
||||||
|
const navigationItems = computed(() => {
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
label: 'Interest List',
|
||||||
|
icon: 'mdi-account-multiple',
|
||||||
|
to: '/dashboard/interest-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Analytics',
|
||||||
|
icon: 'mdi-chart-bar',
|
||||||
|
to: '/dashboard/interest-analytics',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Berth List',
|
||||||
|
icon: 'mdi-table',
|
||||||
|
to: '/dashboard/interest-berth-list',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Berth Status',
|
||||||
|
icon: 'mdi-map',
|
||||||
|
to: '/dashboard/interest-berth-status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Interest Status',
|
||||||
|
icon: 'mdi-clipboard-check',
|
||||||
|
to: '/dashboard/interest-status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'File Browser',
|
||||||
|
icon: 'mdi-folder-open',
|
||||||
|
to: '/dashboard/file-browser',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add sales/admin specific items
|
||||||
|
if (authState.value?.groups?.includes('sales') || authState.value?.groups?.includes('admin')) {
|
||||||
|
items.push(
|
||||||
|
{
|
||||||
|
label: 'Expenses',
|
||||||
|
icon: 'mdi-receipt',
|
||||||
|
to: '/dashboard/expenses',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add admin-only items
|
||||||
|
if (authState.value?.groups?.includes('admin')) {
|
||||||
|
items.push({
|
||||||
|
label: 'Admin Console',
|
||||||
|
icon: 'mdi-shield-crown',
|
||||||
|
to: '/dashboard/admin',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout handler
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await $fetch('/api/auth/logout', { method: 'POST' });
|
||||||
|
await router.push('/login');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
// Even if logout fails, redirect to login
|
||||||
|
await router.push('/login');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize drawer state on mobile
|
||||||
|
onMounted(() => {
|
||||||
|
if (mdAndDown.value) {
|
||||||
|
drawer.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.border-b {
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.12) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve rail mode appearance */
|
||||||
|
.v-navigation-drawer--rail {
|
||||||
|
width: 72px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer--rail .v-list-item {
|
||||||
|
padding-inline-start: 12px !important;
|
||||||
|
padding-inline-end: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer--rail .v-list-item__prepend {
|
||||||
|
margin-inline-end: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer--rail .v-list-item__append {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer--rail.v-navigation-drawer--is-hovering .v-list-item__append {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper mobile responsiveness */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.v-navigation-drawer {
|
||||||
|
position: fixed !important;
|
||||||
|
z-index: 1004 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-main {
|
||||||
|
padding-left: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* PWA optimizations */
|
||||||
|
@media (display-mode: standalone) {
|
||||||
|
.v-app-bar {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve visual alignment */
|
||||||
|
.v-list-item__prepend > .v-avatar {
|
||||||
|
margin-inline-end: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer--rail .v-list-item__prepend > .v-avatar {
|
||||||
|
margin-inline-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Center logo in rail mode */
|
||||||
|
.v-navigation-drawer--rail .v-list-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding-inline-start: 16px !important;
|
||||||
|
padding-inline-end: 16px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.v-navigation-drawer--rail .v-list-item__prepend {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
.v-navigation-drawer,
|
||||||
|
.v-list-item__content,
|
||||||
|
.v-list-item__prepend {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { sessionManager } from '~/server/utils/session-manager'
|
||||||
|
|
||||||
export default defineNuxtRouteMiddleware(async (to) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
// Skip auth for SSR
|
// Skip auth for SSR
|
||||||
if (import.meta.server) return;
|
if (import.meta.server) return;
|
||||||
|
|
@ -17,59 +19,59 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
|
||||||
console.log('[MIDDLEWARE] Checking authentication for route:', to.path);
|
console.log('[MIDDLEWARE] Checking authentication for route:', to.path);
|
||||||
|
|
||||||
// Use a cached auth state to avoid excessive API calls
|
// Use session manager for centralized session handling
|
||||||
const nuxtApp = useNuxtApp();
|
const nuxtApp = useNuxtApp();
|
||||||
const cacheKey = 'auth:session:cache';
|
const cacheKey = 'auth:session:cache';
|
||||||
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
|
const baseExpiry = 3 * 60 * 1000; // 3 minutes base cache
|
||||||
|
const jitter = Math.floor(Math.random() * 10000); // 0-10 seconds jitter
|
||||||
// Check if we have a cached session
|
const cacheExpiry = baseExpiry + jitter; // Prevent thundering herd
|
||||||
const cachedSession = nuxtApp.payload.data?.[cacheKey];
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) {
|
|
||||||
console.log('[MIDDLEWARE] Using cached session');
|
|
||||||
if (cachedSession.authenticated && cachedSession.user) {
|
|
||||||
// Store auth state for components
|
|
||||||
if (!nuxtApp.payload.data) {
|
|
||||||
nuxtApp.payload.data = {};
|
|
||||||
}
|
|
||||||
nuxtApp.payload.data.authState = {
|
|
||||||
user: cachedSession.user,
|
|
||||||
authenticated: cachedSession.authenticated,
|
|
||||||
groups: cachedSession.groups || []
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return navigateTo('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check Keycloak authentication via session API with timeout and retries
|
// Use SessionManager for deduped session checks
|
||||||
|
const sessionData = await sessionManager.checkSession({
|
||||||
|
nuxtApp,
|
||||||
|
cacheKey,
|
||||||
|
cacheExpiry,
|
||||||
|
fetchFn: async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5)
|
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||||
|
|
||||||
const sessionData = await $fetch('/api/auth/session', {
|
try {
|
||||||
|
const result = await $fetch('/api/auth/session', {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
retry: 2, // Increased retry count
|
retry: 2,
|
||||||
retryDelay: 1000, // Increased retry delay
|
retryDelay: 1000,
|
||||||
onRetry: ({ retries }: { retries: number }) => {
|
onRetry: ({ retries }: { retries: number }) => {
|
||||||
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
||||||
|
},
|
||||||
|
onResponseError({ response }) {
|
||||||
|
// Clear cache only on actual auth errors, not 404s or other errors
|
||||||
|
if (response.status === 401) {
|
||||||
|
console.log('[MIDDLEWARE] Unauthorized error detected, clearing cache')
|
||||||
|
sessionManager.clearCache();
|
||||||
|
delete nuxtApp.payload.data?.authState;
|
||||||
|
} else if (response.status === 403) {
|
||||||
|
console.log('[MIDDLEWARE] Forbidden error detected, partial cache clear')
|
||||||
|
// Don't clear cache on 403 as user is authenticated but lacks permissions
|
||||||
|
}
|
||||||
|
// Ignore 404s and other errors - they're not authentication issues
|
||||||
}
|
}
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cache the session data
|
// Store auth state for components
|
||||||
if (!nuxtApp.payload.data) {
|
if (!nuxtApp.payload.data) {
|
||||||
nuxtApp.payload.data = {};
|
nuxtApp.payload.data = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
nuxtApp.payload.data[cacheKey] = {
|
|
||||||
...sessionData,
|
|
||||||
timestamp: now
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store auth state for components
|
|
||||||
nuxtApp.payload.data.authState = {
|
nuxtApp.payload.data.authState = {
|
||||||
user: sessionData.user,
|
user: sessionData.user,
|
||||||
authenticated: sessionData.authenticated,
|
authenticated: sessionData.authenticated,
|
||||||
|
|
@ -80,7 +82,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
authenticated: sessionData.authenticated,
|
authenticated: sessionData.authenticated,
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
userId: sessionData.user?.id,
|
userId: sessionData.user?.id,
|
||||||
groups: sessionData.groups || []
|
groups: sessionData.groups || [],
|
||||||
|
fromCache: sessionData.fromCache,
|
||||||
|
reason: sessionData.reason
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionData.authenticated && sessionData.user) {
|
if (sessionData.authenticated && sessionData.user) {
|
||||||
|
|
@ -102,34 +106,12 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||||
|
|
||||||
// If it's a network error or timeout, check if we have a recent cached session
|
// Show warning for cached results due to network errors
|
||||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
if (error.reason === 'NETWORK_ERROR_CACHED') {
|
||||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
|
||||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
|
||||||
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 30 * 60 * 1000) { // 30 minutes grace period
|
|
||||||
console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)');
|
|
||||||
if (recentCache.authenticated && recentCache.user) {
|
|
||||||
// Store auth state for components
|
|
||||||
if (!nuxtApp.payload.data) {
|
|
||||||
nuxtApp.payload.data = {};
|
|
||||||
}
|
|
||||||
nuxtApp.payload.data.authState = {
|
|
||||||
user: recentCache.user,
|
|
||||||
authenticated: recentCache.authenticated,
|
|
||||||
groups: recentCache.groups || []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show a warning toast if cache is older than 10 minutes
|
|
||||||
if ((now - recentCache.timestamp) > 10 * 60 * 1000) {
|
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
toast.warning('Network connectivity issue - using cached authentication');
|
toast.warning('Network connectivity issue - using cached authentication');
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return navigateTo('/login');
|
return navigateTo('/login');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,29 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles);
|
console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current session data with groups
|
// Get auth state from authentication middleware (already cached)
|
||||||
const sessionData = await $fetch('/api/auth/session') as any;
|
const nuxtApp = useNuxtApp();
|
||||||
|
const authState = nuxtApp.payload?.data?.authState;
|
||||||
|
|
||||||
if (!sessionData.authenticated || !sessionData.user) {
|
// If auth state not available, authentication middleware hasn't run or failed
|
||||||
|
if (!authState || !authState.authenticated || !authState.user) {
|
||||||
|
console.log('[AUTHORIZATION] No auth state found from authentication middleware');
|
||||||
|
|
||||||
|
// Try to get from session cache as fallback
|
||||||
|
const sessionCache = nuxtApp.payload?.data?.['auth:session:cache'];
|
||||||
|
if (!sessionCache || !sessionCache.authenticated) {
|
||||||
console.log('[AUTHORIZATION] User not authenticated, redirecting to login');
|
console.log('[AUTHORIZATION] User not authenticated, redirecting to login');
|
||||||
return navigateTo('/login');
|
return navigateTo('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use cached session
|
||||||
|
authState.user = sessionCache.user;
|
||||||
|
authState.groups = sessionCache.groups || [];
|
||||||
|
}
|
||||||
|
|
||||||
// Get required roles for this route
|
// Get required roles for this route
|
||||||
const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles];
|
const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles];
|
||||||
const userGroups = sessionData.groups || [];
|
const userGroups = authState.groups || [];
|
||||||
|
|
||||||
// Check if user has any of the required roles
|
// Check if user has any of the required roles
|
||||||
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
|
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
|
||||||
|
|
@ -29,29 +41,20 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
|
console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
|
||||||
|
|
||||||
// Store the error in nuxtApp to show toast on redirect
|
// Store the error in nuxtApp to show toast on redirect
|
||||||
const nuxtApp = useNuxtApp();
|
|
||||||
nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`;
|
nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`;
|
||||||
|
|
||||||
// Redirect to dashboard instead of login since user is authenticated
|
// Redirect to dashboard instead of login since user is authenticated
|
||||||
return navigateTo('/dashboard');
|
return navigateTo('/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store auth state in nuxtApp for use by components
|
|
||||||
const nuxtApp = useNuxtApp();
|
|
||||||
if (!nuxtApp.payload.data) {
|
|
||||||
nuxtApp.payload.data = {};
|
|
||||||
}
|
|
||||||
nuxtApp.payload.data.authState = {
|
|
||||||
user: sessionData.user,
|
|
||||||
authenticated: sessionData.authenticated,
|
|
||||||
groups: sessionData.groups || []
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[AUTHORIZATION] Access granted for route:', to.path);
|
console.log('[AUTHORIZATION] Access granted for route:', to.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTHORIZATION] Error checking route access:', error);
|
console.error('[AUTHORIZATION] Error checking route access:', error);
|
||||||
|
|
||||||
// If session check fails, redirect to login
|
// Don't automatically redirect to login on errors
|
||||||
return navigateTo('/login');
|
// Let the authentication middleware handle auth failures
|
||||||
|
const toast = useToast();
|
||||||
|
toast.error('Failed to verify permissions. Please try again.');
|
||||||
|
return navigateTo('/dashboard');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -8,6 +8,7 @@
|
||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/ui": "^3.2.0",
|
||||||
"@pdfme/common": "^5.4.0",
|
"@pdfme/common": "^5.4.0",
|
||||||
"@pdfme/generator": "^5.4.0",
|
"@pdfme/generator": "^5.4.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
|
|
||||||
|
|
@ -1,293 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<v-app full-height>
|
<div>
|
||||||
<v-navigation-drawer
|
<!-- This page now acts as a parent route for dashboard pages -->
|
||||||
v-model="drawer"
|
<NuxtPage />
|
||||||
:location="mdAndDown ? 'bottom' : undefined"
|
</div>
|
||||||
>
|
|
||||||
<v-img v-if="!mdAndDown" src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="110" class="my-6" contain />
|
|
||||||
|
|
||||||
<v-list color="primary" lines="two">
|
|
||||||
<v-list-item
|
|
||||||
v-for="(item, index) in safeMenu"
|
|
||||||
:key="index"
|
|
||||||
:to="item.to"
|
|
||||||
:title="item.title"
|
|
||||||
:prepend-icon="item.icon"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
|
|
||||||
<template #append>
|
|
||||||
<v-list lines="two">
|
|
||||||
<v-list-item
|
|
||||||
v-if="user"
|
|
||||||
:title="user.name"
|
|
||||||
:subtitle="user.email"
|
|
||||||
prepend-icon="mdi-account"
|
|
||||||
>
|
|
||||||
<template #append>
|
|
||||||
<v-chip v-if="user.tier && user.tier !== 'basic'" size="small" color="primary">
|
|
||||||
{{ user.tier }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item
|
|
||||||
@click="logOut"
|
|
||||||
title="Log out"
|
|
||||||
prepend-icon="mdi-logout"
|
|
||||||
base-color="error"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
|
|
||||||
<v-app-bar v-if="mdAndDown" elevation="2">
|
|
||||||
<template #prepend>
|
|
||||||
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="50" />
|
|
||||||
|
|
||||||
<template #append>
|
|
||||||
<v-btn
|
|
||||||
@click="logOut"
|
|
||||||
class="mr-3"
|
|
||||||
variant="text"
|
|
||||||
color="error"
|
|
||||||
icon="mdi-logout"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
|
||||||
<router-view />
|
|
||||||
</v-main>
|
|
||||||
</v-app>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["authentication"],
|
middleware: ["authentication"],
|
||||||
layout: false,
|
layout: "dashboard-unified",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mdAndDown } = useDisplay();
|
|
||||||
const { user, logout, authSource } = useUnifiedAuth();
|
|
||||||
const { isAdmin, getUserGroups, getCurrentUser } = useAuthorization();
|
|
||||||
const tags = usePortalTags();
|
|
||||||
|
|
||||||
const drawer = ref(false);
|
|
||||||
|
|
||||||
// Debug auth state
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
console.log('[Dashboard] Auth state on mount:', {
|
|
||||||
isAdmin: isAdmin(),
|
|
||||||
userGroups: getUserGroups(),
|
|
||||||
currentUser: getCurrentUser()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const interestMenu = computed(() => {
|
|
||||||
const userIsAdmin = isAdmin();
|
|
||||||
const userGroups = getUserGroups();
|
|
||||||
|
|
||||||
console.log('[Dashboard] Computing interest menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
|
|
||||||
|
|
||||||
// Check if user has sales or admin privileges
|
|
||||||
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
|
|
||||||
|
|
||||||
const baseMenu = [
|
|
||||||
//{
|
|
||||||
// to: "/dashboard/interest-eoi-queue",
|
|
||||||
// icon: "mdi-tray-full",
|
|
||||||
// title: "EOI Queue",
|
|
||||||
//},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-analytics",
|
|
||||||
icon: "mdi-view-dashboard",
|
|
||||||
title: "Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-berth-list",
|
|
||||||
icon: "mdi-table",
|
|
||||||
title: "Berth List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-berth-status",
|
|
||||||
icon: "mdi-sail-boat",
|
|
||||||
title: "Berth Status",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-list",
|
|
||||||
icon: "mdi-view-list",
|
|
||||||
title: "Interest List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-status",
|
|
||||||
icon: "mdi-account-check",
|
|
||||||
title: "Interest Status",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/file-browser",
|
|
||||||
icon: "mdi-folder",
|
|
||||||
title: "File Browser",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only show expenses to sales and admin users
|
|
||||||
if (hasSalesAccess) {
|
|
||||||
console.log('[Dashboard] Adding expenses to menu (user has sales/admin access)');
|
|
||||||
baseMenu.push({
|
|
||||||
to: "/dashboard/expenses",
|
|
||||||
icon: "mdi-receipt",
|
|
||||||
title: "Expenses",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('[Dashboard] Hiding expenses from menu (user role:', userGroups, ')');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add admin menu items if user is admin
|
|
||||||
if (userIsAdmin) {
|
|
||||||
console.log('[Dashboard] Adding admin console to interest menu');
|
|
||||||
baseMenu.push({
|
|
||||||
to: "/dashboard/admin",
|
|
||||||
icon: "mdi-shield-crown",
|
|
||||||
title: "Admin Console",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseMenu;
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultMenu = computed(() => {
|
|
||||||
const userIsAdmin = isAdmin();
|
|
||||||
const userGroups = getUserGroups();
|
|
||||||
|
|
||||||
console.log('[Dashboard] Computing default menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
|
|
||||||
|
|
||||||
const baseMenu = [
|
|
||||||
{
|
|
||||||
to: "/dashboard/site",
|
|
||||||
icon: "mdi-view-dashboard",
|
|
||||||
title: "Site Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/data",
|
|
||||||
icon: "mdi-finance",
|
|
||||||
title: "Data Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/file-browser",
|
|
||||||
icon: "mdi-folder",
|
|
||||||
title: "File Browser",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add admin menu items if user is admin
|
|
||||||
if (userIsAdmin) {
|
|
||||||
console.log('[Dashboard] Adding admin console to default menu');
|
|
||||||
baseMenu.push({
|
|
||||||
to: "/dashboard/admin",
|
|
||||||
icon: "mdi-shield-crown",
|
|
||||||
title: "Admin Console",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseMenu;
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = computed(() => {
|
|
||||||
try {
|
|
||||||
const tagsValue = toValue(tags);
|
|
||||||
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
|
|
||||||
|
|
||||||
console.log('[Dashboard] Computing menu:', {
|
|
||||||
hasInterestTag: tagsValue.interest,
|
|
||||||
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
|
|
||||||
menuIsArray: Array.isArray(menuToUse),
|
|
||||||
menuLength: menuToUse?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return menuToUse;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Dashboard] Error computing menu:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Safe menu wrapper to prevent crashes when menu is undefined
|
|
||||||
const safeMenu = computed(() => {
|
|
||||||
try {
|
|
||||||
const currentMenu = menu.value;
|
|
||||||
if (Array.isArray(currentMenu)) {
|
|
||||||
return currentMenu;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('[Dashboard] Menu is not an array, returning fallback menu');
|
|
||||||
|
|
||||||
// Get current user permissions for fallback menu
|
|
||||||
const userIsAdmin = isAdmin();
|
|
||||||
const userGroups = getUserGroups();
|
|
||||||
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
|
|
||||||
|
|
||||||
// Fallback menu with essential items (respecting permissions)
|
|
||||||
const fallbackMenu = [
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-list",
|
|
||||||
icon: "mdi-view-list",
|
|
||||||
title: "Interest List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/file-browser",
|
|
||||||
icon: "mdi-folder",
|
|
||||||
title: "File Browser",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only add expenses if user has sales/admin access
|
|
||||||
if (hasSalesAccess) {
|
|
||||||
fallbackMenu.push({
|
|
||||||
to: "/dashboard/expenses",
|
|
||||||
icon: "mdi-receipt",
|
|
||||||
title: "Expenses",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add admin console if user is admin
|
|
||||||
if (userIsAdmin) {
|
|
||||||
fallbackMenu.push({
|
|
||||||
to: "/dashboard/admin",
|
|
||||||
icon: "mdi-shield-crown",
|
|
||||||
title: "Admin Console",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackMenu;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Dashboard] Error computing menu:', error);
|
|
||||||
|
|
||||||
// Emergency fallback menu - only essential items
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-list",
|
|
||||||
icon: "mdi-view-list",
|
|
||||||
title: "Interest List",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const logOut = async () => {
|
|
||||||
await logout();
|
|
||||||
return navigateTo("/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (mdAndDown.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawer.value = true;
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -238,6 +238,7 @@ import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication', 'authorization'],
|
middleware: ['authentication', 'authorization'],
|
||||||
|
layout: 'dashboard-unified',
|
||||||
auth: {
|
auth: {
|
||||||
roles: ['admin']
|
roles: ['admin']
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -230,6 +230,7 @@ import { formatTime, formatDateTime } from '@/utils/dateUtils'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication', 'authorization'],
|
middleware: ['authentication', 'authorization'],
|
||||||
|
layout: 'dashboard-unified',
|
||||||
auth: {
|
auth: {
|
||||||
roles: ['admin']
|
roles: ['admin']
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'dashboard-unified'
|
||||||
|
});
|
||||||
|
|
||||||
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<v-card class="mb-6">
|
<v-card class="mb-6">
|
||||||
<v-card-text class="pa-6">
|
<v-card-text class="pa-6">
|
||||||
<v-row align="center" class="mb-0">
|
<v-row align="center" class="mb-0">
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="filters.startDate"
|
v-model="filters.startDate"
|
||||||
type="date"
|
type="date"
|
||||||
|
|
@ -32,11 +32,11 @@
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details
|
hide-details
|
||||||
@change="fetchExpenses"
|
class="date-input-fix"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="filters.endDate"
|
v-model="filters.endDate"
|
||||||
type="date"
|
type="date"
|
||||||
|
|
@ -44,11 +44,11 @@
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details
|
hide-details
|
||||||
@change="fetchExpenses"
|
class="date-input-fix"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="filters.category"
|
v-model="filters.category"
|
||||||
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
||||||
|
|
@ -57,15 +57,27 @@
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details
|
hide-details
|
||||||
clearable
|
clearable
|
||||||
@update:model-value="fetchExpenses"
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
|
<v-btn
|
||||||
|
@click="fetchExpenses"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="large"
|
||||||
|
class="w-100"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="resetToCurrentMonth"
|
@click="resetToCurrentMonth"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="default"
|
||||||
class="w-100"
|
class="w-100"
|
||||||
>
|
>
|
||||||
Current Month
|
Current Month
|
||||||
|
|
@ -187,7 +199,7 @@
|
||||||
<div class="d-flex flex-wrap align-center">
|
<div class="d-flex flex-wrap align-center">
|
||||||
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
|
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
|
||||||
|
|
||||||
<div class="d-flex gap-4">
|
<div class="d-flex ga-4">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="exportCSV"
|
@click="exportCSV"
|
||||||
:disabled="selectedExpenses.length === 0"
|
:disabled="selectedExpenses.length === 0"
|
||||||
|
|
@ -336,8 +348,9 @@ const ExpenseCreateModal = defineAsyncComponent(() => import('@/components/Expen
|
||||||
|
|
||||||
// Page meta
|
// Page meta
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication'],
|
middleware: ['authentication', 'authorization'],
|
||||||
layout: 'dashboard'
|
layout: 'dashboard-unified',
|
||||||
|
roles: ['sales', 'admin']
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
|
|
@ -649,4 +662,10 @@ onMounted(async () => {
|
||||||
.v-tab {
|
.v-tab {
|
||||||
text-transform: none !important;
|
text-transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix for date input calendar button positioning */
|
||||||
|
.date-input-fix :deep(.v-field__append-inner) {
|
||||||
|
padding-inline-start: 8px;
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,10 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'dashboard-unified'
|
||||||
|
});
|
||||||
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import FileUploader from '~/components/FileUploader.vue';
|
import FileUploader from '~/components/FileUploader.vue';
|
||||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||||
|
|
|
||||||
|
|
@ -116,12 +116,12 @@
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
|
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
|
||||||
<div class="d-flex flex-column gap-6">
|
<div class="d-flex flex-column">
|
||||||
<v-card
|
<v-card
|
||||||
v-for="berth in getBerthsByStatus(status.value)"
|
v-for="berth in getBerthsByStatus(status.value)"
|
||||||
:key="berth.Id"
|
:key="berth.Id"
|
||||||
@click="handleBerthClick(berth)"
|
@click="handleBerthClick(berth)"
|
||||||
class="berth-kanban-card"
|
class="berth-kanban-card mb-4"
|
||||||
:color="status.color"
|
:color="status.color"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
|
|
@ -137,14 +137,24 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-space-between align-center">
|
<div class="d-flex justify-space-between align-center">
|
||||||
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
||||||
|
<v-tooltip v-if="getInterestedCount(berth)" location="top">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="getInterestedCount(berth)"
|
v-bind="props"
|
||||||
size="x-small"
|
size="x-small"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
>
|
>
|
||||||
{{ getInterestedCount(berth) }} interested
|
{{ getInterestedCount(berth) }} interested
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<div class="pa-2">
|
||||||
|
<div class="text-subtitle-2 mb-1">Interested Parties:</div>
|
||||||
|
<div v-for="party in berth['Interested Parties']" :key="party.Id" class="text-body-2">
|
||||||
|
{{ party['Full Name'] }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h4">
|
||||||
|
New Unified Sidebar Demo
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text>
|
||||||
|
<v-alert type="success" variant="tonal" class="mb-6">
|
||||||
|
<div class="text-h6 mb-2">✨ Modern Sidebar Features</div>
|
||||||
|
<ul class="pl-4">
|
||||||
|
<li>Clean white design with subtle borders</li>
|
||||||
|
<li>Collapsible sidebar with smooth animations</li>
|
||||||
|
<li>Icons that change color when active</li>
|
||||||
|
<li>User info with avatar at the bottom</li>
|
||||||
|
<li>Role badges (Admin/Sales)</li>
|
||||||
|
<li>Responsive - becomes a drawer on mobile</li>
|
||||||
|
<li>Page title in the top navbar</li>
|
||||||
|
</ul>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card variant="outlined">
|
||||||
|
<v-card-title>How to Use</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">Click the chevron icon in the sidebar header to collapse/expand it.</p>
|
||||||
|
<p class="mb-3">On mobile devices, use the hamburger menu to toggle the sidebar.</p>
|
||||||
|
<p>The sidebar automatically adjusts based on your role permissions.</p>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-card variant="outlined">
|
||||||
|
<v-card-title>Navigation Items</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="mb-3">The sidebar shows different menu items based on your role:</p>
|
||||||
|
<ul class="pl-4">
|
||||||
|
<li><strong>All users:</strong> Dashboard, Analytics, Berth List, Interest List, File Browser</li>
|
||||||
|
<li><strong>Sales/Admin:</strong> + Expenses, Interest Emails</li>
|
||||||
|
<li><strong>Admin only:</strong> + Admin Console</li>
|
||||||
|
</ul>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<v-alert type="info" variant="tonal" class="mt-6">
|
||||||
|
<div class="text-subtitle-1 font-weight-medium mb-2">Technical Details</div>
|
||||||
|
<p class="text-body-2">This sidebar uses Nuxt UI's <code>UDashboardSidebar</code> component with custom styling to match your brand colors and maintain a clean, professional look.</p>
|
||||||
|
</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['authentication'],
|
||||||
|
layout: 'dashboard-unified'
|
||||||
|
});
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: 'Sidebar Demo'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
@ -165,15 +165,43 @@ export default defineNuxtPlugin(() => {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
let lastVisibilityChange = Date.now()
|
let lastVisibilityChange = Date.now()
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', async () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const timeSinceLastCheck = now - lastVisibilityChange
|
const timeSinceLastCheck = now - lastVisibilityChange
|
||||||
|
|
||||||
// If tab was hidden for more than 1 minute, check auth status
|
// If tab was hidden for more than 30 seconds, check auth status
|
||||||
if (timeSinceLastCheck > 60000) {
|
if (timeSinceLastCheck > 30000) {
|
||||||
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
|
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
|
||||||
|
|
||||||
|
// Force immediate session validation
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/session', {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || response.status === 401) {
|
||||||
|
console.log('[AUTH_REFRESH] Session expired while tab was hidden')
|
||||||
|
await navigateTo('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await response.json()
|
||||||
|
if (!sessionData.authenticated) {
|
||||||
|
console.log('[AUTH_REFRESH] Not authenticated after tab visibility')
|
||||||
|
await navigateTo('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-schedule refresh if session is valid
|
||||||
checkAndScheduleRefresh()
|
checkAndScheduleRefresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH_REFRESH] Failed to check session on visibility change:', error)
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
lastVisibilityChange = now
|
lastVisibilityChange = now
|
||||||
|
|
@ -181,11 +209,71 @@ export default defineNuxtPlugin(() => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up timer on plugin destruction
|
// Add periodic session validation (every 5 minutes instead of 2)
|
||||||
|
let validationInterval: NodeJS.Timeout | null = null
|
||||||
|
let isValidating = false // Prevent concurrent validations
|
||||||
|
let failureCount = 0 // Track consecutive failures
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Add random offset to prevent all clients checking at once
|
||||||
|
const randomOffset = Math.floor(Math.random() * 10000) // 0-10 seconds
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
validationInterval = setInterval(async () => {
|
||||||
|
if (isValidating) return // Skip if already validating
|
||||||
|
|
||||||
|
isValidating = true
|
||||||
|
console.log('[AUTH_REFRESH] Performing periodic session validation')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/session', {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok || response.status === 401) {
|
||||||
|
failureCount++
|
||||||
|
console.log(`[AUTH_REFRESH] Session check failed (attempt ${failureCount}/3)`)
|
||||||
|
|
||||||
|
// Only logout after 3 consecutive failures
|
||||||
|
if (failureCount >= 3) {
|
||||||
|
console.log('[AUTH_REFRESH] Session invalid after 3 attempts, redirecting to login')
|
||||||
|
clearInterval(validationInterval!)
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset failure count on success
|
||||||
|
failureCount = 0
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH_REFRESH] Periodic validation error:', error)
|
||||||
|
// Don't logout on network errors - let middleware handle it
|
||||||
|
// But count it as a failure for resilience
|
||||||
|
failureCount++
|
||||||
|
|
||||||
|
if (failureCount >= 3) {
|
||||||
|
console.log('[AUTH_REFRESH] Too many validation errors, redirecting to login')
|
||||||
|
clearInterval(validationInterval!)
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isValidating = false
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000) // Changed to 5 minutes to avoid conflicts with 3-minute cache
|
||||||
|
}, randomOffset)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clean up timers on plugin destruction
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
if (refreshTimer) {
|
if (refreshTimer) {
|
||||||
clearTimeout(refreshTimer)
|
clearTimeout(refreshTimer)
|
||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
}
|
}
|
||||||
|
if (validationInterval) {
|
||||||
|
clearInterval(validationInterval)
|
||||||
|
validationInterval = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
// Only run on client side
|
||||||
|
if (import.meta.server) return
|
||||||
|
|
||||||
|
const nuxtApp = useNuxtApp()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// Global error handler for API requests
|
||||||
|
nuxtApp.hook('app:error', (error: any) => {
|
||||||
|
console.error('[AUTH_ERROR_HANDLER] Application error:', error)
|
||||||
|
|
||||||
|
// Handle authentication errors
|
||||||
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||||
|
handleAuthError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Intercept $fetch errors globally
|
||||||
|
const originalFetch = globalThis.$fetch
|
||||||
|
globalThis.$fetch = $fetch.create({
|
||||||
|
onResponseError({ response }) {
|
||||||
|
console.log('[AUTH_ERROR_HANDLER] Response error:', {
|
||||||
|
status: response.status,
|
||||||
|
url: response.url,
|
||||||
|
statusText: response.statusText
|
||||||
|
})
|
||||||
|
|
||||||
|
// Only handle authentication errors from our own API endpoints
|
||||||
|
const isAuthEndpoint = response.url && (
|
||||||
|
response.url.includes('/api/auth/') ||
|
||||||
|
response.url.includes('/api/') && !response.url.includes('cms.portnimara.dev') && !response.url.includes('database.portnimara.com')
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handle authentication errors (401, 403) only from our API
|
||||||
|
if ((response.status === 401 || response.status === 403) && isAuthEndpoint) {
|
||||||
|
console.log('[AUTH_ERROR_HANDLER] Authentication error from app endpoint')
|
||||||
|
handleAuthError({
|
||||||
|
statusCode: response.status,
|
||||||
|
statusMessage: response.statusText,
|
||||||
|
data: response._data
|
||||||
|
})
|
||||||
|
} else if (response.status === 401 && !isAuthEndpoint) {
|
||||||
|
console.log('[AUTH_ERROR_HANDLER] Ignoring 401 from external service:', response.url)
|
||||||
|
// Don't clear auth for external service 401s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 404 errors that might be auth-related
|
||||||
|
if (response.status === 404 && isProtectedRoute() && isAuthEndpoint) {
|
||||||
|
console.warn('[AUTH_ERROR_HANDLER] 404 on protected route from app endpoint, may be auth-related')
|
||||||
|
// Check if session is still valid
|
||||||
|
checkAndHandleSession()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAuthError = async (error: any) => {
|
||||||
|
console.error('[AUTH_ERROR_HANDLER] Authentication error detected:', error)
|
||||||
|
|
||||||
|
// Clear all auth-related caches
|
||||||
|
clearAuthCaches()
|
||||||
|
|
||||||
|
// Only show toast and redirect if we're not already on the login page
|
||||||
|
const route = useRoute()
|
||||||
|
if (route.path !== '/login' && !route.path.startsWith('/auth')) {
|
||||||
|
toast.error('Your session has expired. Please log in again.')
|
||||||
|
|
||||||
|
// Delay navigation slightly to ensure toast is visible
|
||||||
|
setTimeout(() => {
|
||||||
|
navigateTo('/login')
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAuthCaches = () => {
|
||||||
|
console.log('[AUTH_ERROR_HANDLER] Clearing authentication caches')
|
||||||
|
|
||||||
|
// Clear Nuxt app payload caches
|
||||||
|
if (nuxtApp.payload.data) {
|
||||||
|
delete nuxtApp.payload.data['auth:session:cache']
|
||||||
|
delete nuxtApp.payload.data.authState
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear session cookie
|
||||||
|
const sessionCookie = useCookie('nuxt-oidc-auth')
|
||||||
|
sessionCookie.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProtectedRoute = () => {
|
||||||
|
const route = useRoute()
|
||||||
|
// Check if current route requires authentication
|
||||||
|
return route.meta.auth !== false &&
|
||||||
|
!route.path.startsWith('/login') &&
|
||||||
|
!route.path.startsWith('/auth')
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkAndHandleSession = async () => {
|
||||||
|
try {
|
||||||
|
// Force a fresh session check without cache
|
||||||
|
const response = await fetch('/api/auth/session', {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Session check failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionData = await response.json()
|
||||||
|
|
||||||
|
if (!sessionData.authenticated) {
|
||||||
|
handleAuthError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Session expired'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AUTH_ERROR_HANDLER] Failed to check session:', error)
|
||||||
|
handleAuthError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'Session check failed'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose clearAuthCaches for manual use
|
||||||
|
nuxtApp.provide('clearAuthCaches', clearAuthCaches)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
mkdir: cannot create directory ‘C:\\Users\\mpcia\\Documents\\Cline\\MCP’: File exists
|
||||||
|
hello
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth';
|
import { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth';
|
||||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||||
|
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[DUPLICATES] Find duplicates request');
|
console.log('[ADMIN] Find duplicates request');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Require sales or admin access for duplicate detection
|
// Require sales or admin access for duplicate detection
|
||||||
|
|
@ -26,17 +27,27 @@ export default defineEventHandler(async (event) => {
|
||||||
const interests = response.list || [];
|
const interests = response.list || [];
|
||||||
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
|
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
|
||||||
|
|
||||||
// Find potential duplicates
|
// Find duplicate groups using the new centralized utility
|
||||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
const duplicateConfig = createInterestConfig();
|
||||||
|
const duplicateGroups = findDuplicates(interests, duplicateConfig);
|
||||||
|
|
||||||
console.log('[ADMIN] Found', duplicateGroups.length, 'duplicate groups');
|
// Convert to the expected format
|
||||||
|
const formattedGroups = duplicateGroups.map(group => ({
|
||||||
|
id: group.id,
|
||||||
|
interests: group.items,
|
||||||
|
matchReason: group.matchReason,
|
||||||
|
confidence: group.confidence,
|
||||||
|
masterCandidate: group.masterCandidate
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('[ADMIN] Found', formattedGroups.length, 'duplicate groups');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
duplicateGroups,
|
duplicateGroups: formattedGroups,
|
||||||
totalInterests: interests.length,
|
totalInterests: interests.length,
|
||||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||||
threshold
|
threshold
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -57,203 +68,3 @@ export default defineEventHandler(async (event) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Find duplicate interests based on multiple criteria
|
|
||||||
*/
|
|
||||||
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|
||||||
const duplicateGroups: Array<{
|
|
||||||
id: string;
|
|
||||||
interests: any[];
|
|
||||||
matchReason: string;
|
|
||||||
confidence: number;
|
|
||||||
masterCandidate: any;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const processedIds = new Set<number>();
|
|
||||||
|
|
||||||
for (let i = 0; i < interests.length; i++) {
|
|
||||||
const interest1 = interests[i];
|
|
||||||
|
|
||||||
if (processedIds.has(interest1.Id)) continue;
|
|
||||||
|
|
||||||
const matches = [interest1];
|
|
||||||
|
|
||||||
for (let j = i + 1; j < interests.length; j++) {
|
|
||||||
const interest2 = interests[j];
|
|
||||||
|
|
||||||
if (processedIds.has(interest2.Id)) continue;
|
|
||||||
|
|
||||||
const similarity = calculateSimilarity(interest1, interest2);
|
|
||||||
|
|
||||||
if (similarity.score >= threshold) {
|
|
||||||
matches.push(interest2);
|
|
||||||
processedIds.add(interest2.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length > 1) {
|
|
||||||
// Mark all as processed
|
|
||||||
matches.forEach(match => processedIds.add(match.Id));
|
|
||||||
|
|
||||||
// Determine the best master candidate (most complete record)
|
|
||||||
const masterCandidate = selectMasterCandidate(matches);
|
|
||||||
|
|
||||||
duplicateGroups.push({
|
|
||||||
id: `group_${duplicateGroups.length + 1}`,
|
|
||||||
interests: matches,
|
|
||||||
matchReason: 'Multiple matching criteria',
|
|
||||||
confidence: Math.max(...matches.slice(1).map(match =>
|
|
||||||
calculateSimilarity(masterCandidate, match).score
|
|
||||||
)),
|
|
||||||
masterCandidate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return duplicateGroups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two interests
|
|
||||||
*/
|
|
||||||
function calculateSimilarity(interest1: any, interest2: any) {
|
|
||||||
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
|
||||||
|
|
||||||
// Email similarity (highest weight)
|
|
||||||
if (interest1['Email Address'] && interest2['Email Address']) {
|
|
||||||
const emailScore = interest1['Email Address'].toLowerCase() === interest2['Email Address'].toLowerCase() ? 1.0 : 0.0;
|
|
||||||
scores.push({ type: 'email', score: emailScore, weight: 0.4 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phone similarity
|
|
||||||
if (interest1['Phone Number'] && interest2['Phone Number']) {
|
|
||||||
const phone1 = normalizePhone(interest1['Phone Number']);
|
|
||||||
const phone2 = normalizePhone(interest2['Phone Number']);
|
|
||||||
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
|
|
||||||
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name similarity
|
|
||||||
if (interest1['Full Name'] && interest2['Full Name']) {
|
|
||||||
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
|
|
||||||
scores.push({ type: 'name', score: nameScore, weight: 0.2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address similarity
|
|
||||||
if (interest1.Address && interest2.Address) {
|
|
||||||
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
|
|
||||||
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate weighted average
|
|
||||||
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
|
||||||
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: weightedScore,
|
|
||||||
details: scores
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize phone number for comparison
|
|
||||||
*/
|
|
||||||
function normalizePhone(phone: string): string {
|
|
||||||
return phone.replace(/\D/g, ''); // Remove all non-digits
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate name similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateNameSimilarity(name1: string, name2: string): number {
|
|
||||||
const str1 = name1.toLowerCase().trim();
|
|
||||||
const str2 = name2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (str1 === str2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(str1, str2);
|
|
||||||
const maxLength = Math.max(str1.length, str2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate string similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateStringSimilarity(str1: string, str2: string): number {
|
|
||||||
const s1 = str1.toLowerCase().trim();
|
|
||||||
const s2 = str2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (s1 === s2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(s1, s2);
|
|
||||||
const maxLength = Math.max(s1.length, s2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Levenshtein distance between two strings
|
|
||||||
*/
|
|
||||||
function levenshteinDistance(str1: string, str2: string): number {
|
|
||||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i <= str1.length; i += 1) {
|
|
||||||
matrix[0][i] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 0; j <= str2.length; j += 1) {
|
|
||||||
matrix[j][0] = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 1; j <= str2.length; j += 1) {
|
|
||||||
for (let i = 1; i <= str1.length; i += 1) {
|
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
||||||
matrix[j][i] = Math.min(
|
|
||||||
matrix[j][i - 1] + 1, // deletion
|
|
||||||
matrix[j - 1][i] + 1, // insertion
|
|
||||||
matrix[j - 1][i - 1] + indicator // substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix[str2.length][str1.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the best master candidate from a group of duplicates
|
|
||||||
*/
|
|
||||||
function selectMasterCandidate(interests: any[]) {
|
|
||||||
return interests.reduce((best, current) => {
|
|
||||||
const bestScore = calculateCompletenessScore(best);
|
|
||||||
const currentScore = calculateCompletenessScore(current);
|
|
||||||
|
|
||||||
return currentScore > bestScore ? current : best;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate completeness score for an interest record
|
|
||||||
*/
|
|
||||||
function calculateCompletenessScore(interest: any): number {
|
|
||||||
const fields = ['Full Name', 'Email Address', 'Phone Number', 'Address', 'Extra Comments', 'Berth Size Desired'];
|
|
||||||
const filledFields = fields.filter(field =>
|
|
||||||
interest[field] && interest[field].toString().trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
let score = filledFields.length / fields.length;
|
|
||||||
|
|
||||||
// Bonus for recent creation
|
|
||||||
if (interest['Created At']) {
|
|
||||||
const created = new Date(interest['Created At']);
|
|
||||||
const now = new Date();
|
|
||||||
const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
// More recent records get a small bonus
|
|
||||||
if (daysOld < 30) score += 0.1;
|
|
||||||
else if (daysOld < 90) score += 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,61 @@ export default defineEventHandler(async (event) => {
|
||||||
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
|
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
|
||||||
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
|
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
|
||||||
|
|
||||||
// Redirect to dashboard
|
// Return HTML with client-side redirect for SPA compatibility
|
||||||
await sendRedirect(event, '/dashboard')
|
setHeader(event, 'Content-Type', 'text/html')
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Successful - Port Nimara Portal</title>
|
||||||
|
<meta http-equiv="refresh" content="0;url=/dashboard">
|
||||||
|
<script>
|
||||||
|
// Immediate redirect
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #387bca 0%, #2c5aa0 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h2>Authentication successful!</h2>
|
||||||
|
<p>Redirecting to dashboard...</p>
|
||||||
|
<p><small>If you are not redirected automatically, <a href="/dashboard" style="color: #ffffff;">click here</a>.</small></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,15 @@ import { keycloakClient } from '~/server/utils/keycloak-client'
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
console.log('[REFRESH] Processing token refresh request')
|
const requestId = Math.random().toString(36).substring(7)
|
||||||
|
console.log(`[REFRESH:${requestId}] Processing token refresh request`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current session
|
// Get current session
|
||||||
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
||||||
|
|
||||||
if (!oidcSession) {
|
if (!oidcSession) {
|
||||||
console.error('[REFRESH] No session found')
|
console.error(`[REFRESH:${requestId}] No session found`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'No session found'
|
statusMessage: 'No session found'
|
||||||
|
|
@ -20,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
sessionData = JSON.parse(oidcSession)
|
sessionData = JSON.parse(oidcSession)
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[REFRESH] Failed to parse session:', parseError)
|
console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Invalid session format'
|
statusMessage: 'Invalid session format'
|
||||||
|
|
@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// Check if we have a refresh token
|
// Check if we have a refresh token
|
||||||
if (!sessionData.refreshToken) {
|
if (!sessionData.refreshToken) {
|
||||||
console.error('[REFRESH] No refresh token available')
|
console.error(`[REFRESH:${requestId}] No refresh token available`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'No refresh token available'
|
statusMessage: 'No refresh token available'
|
||||||
|
|
@ -39,24 +40,48 @@ export default defineEventHandler(async (event) => {
|
||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
||||||
if (!clientSecret) {
|
if (!clientSecret) {
|
||||||
console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
|
console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Authentication service misconfigured'
|
statusMessage: 'Authentication service misconfigured'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use refresh token to get new access token with retry logic
|
// Use refresh token to get new access token with enhanced error handling
|
||||||
console.log('[REFRESH] Using Keycloak client for token refresh...')
|
console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`)
|
||||||
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
||||||
|
.catch((error: any) => {
|
||||||
|
// Check if it's a transient error
|
||||||
|
if (error.statusMessage === 'KEYCLOAK_TEMPORARILY_UNAVAILABLE') {
|
||||||
|
console.log(`[REFRESH:${requestId}] Keycloak temporarily unavailable, using grace period`)
|
||||||
|
// Return current session with extended grace period
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
expiresAt: sessionData.expiresAt,
|
||||||
|
gracePeriod: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error // Re-throw for permanent failures
|
||||||
|
})
|
||||||
|
|
||||||
const refreshDuration = Date.now() - startTime
|
const refreshDuration = Date.now() - startTime
|
||||||
console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, {
|
console.log(`[REFRESH:${requestId}] Token refresh successful in ${refreshDuration}ms:`, {
|
||||||
hasAccessToken: !!tokenResponse.access_token,
|
hasAccessToken: !!tokenResponse.access_token,
|
||||||
hasRefreshToken: !!tokenResponse.refresh_token,
|
hasRefreshToken: !!tokenResponse.refresh_token,
|
||||||
expiresIn: tokenResponse.expires_in
|
expiresIn: tokenResponse.expires_in,
|
||||||
|
gracePeriod: tokenResponse.gracePeriod
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle grace period response
|
||||||
|
if (tokenResponse.gracePeriod) {
|
||||||
|
console.log(`[REFRESH:${requestId}] Using grace period - session extended`)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
expiresAt: tokenResponse.expiresAt,
|
||||||
|
gracePeriod: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update session with new tokens
|
// Update session with new tokens
|
||||||
const updatedSessionData = {
|
const updatedSessionData = {
|
||||||
...sessionData,
|
...sessionData,
|
||||||
|
|
@ -79,7 +104,7 @@ export default defineEventHandler(async (event) => {
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[REFRESH] Session updated successfully')
|
console.log(`[REFRESH:${requestId}] Session updated successfully`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -87,14 +112,17 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[REFRESH] Token refresh failed:', error)
|
console.error(`[REFRESH:${requestId}] Token refresh failed:`, error)
|
||||||
|
|
||||||
// Clear invalid session
|
// Only clear session for permanent failures
|
||||||
|
if (error.statusMessage === 'REFRESH_TOKEN_INVALID') {
|
||||||
|
console.log(`[REFRESH:${requestId}] Clearing session due to invalid refresh token`)
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,33 @@
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[SESSION] Checking authentication session...')
|
const requestId = Math.random().toString(36).substring(7)
|
||||||
|
const startTime = Date.now()
|
||||||
|
console.log(`[SESSION:${requestId}] Checking authentication session...`)
|
||||||
|
|
||||||
// Check OIDC/Keycloak authentication only
|
// Check OIDC/Keycloak authentication only
|
||||||
try {
|
try {
|
||||||
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
||||||
|
|
||||||
if (!oidcSessionCookie) {
|
if (!oidcSessionCookie) {
|
||||||
console.log('[SESSION] No OIDC session cookie found')
|
console.log(`[SESSION:${requestId}] No OIDC session cookie found`)
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'NO_SESSION_COOKIE',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[SESSION] OIDC session cookie found, parsing...')
|
console.log(`[SESSION:${requestId}] OIDC session cookie found, parsing...`)
|
||||||
|
|
||||||
let sessionData
|
let sessionData
|
||||||
try {
|
try {
|
||||||
// Parse the session data
|
// Parse the session data
|
||||||
|
const parseStart = Date.now()
|
||||||
sessionData = JSON.parse(oidcSessionCookie)
|
sessionData = JSON.parse(oidcSessionCookie)
|
||||||
console.log('[SESSION] Session data parsed successfully:', {
|
const parseTime = Date.now() - parseStart
|
||||||
|
|
||||||
|
console.log(`[SESSION:${requestId}] Session data parsed successfully in ${parseTime}ms:`, {
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
hasAccessToken: !!sessionData.accessToken,
|
hasAccessToken: !!sessionData.accessToken,
|
||||||
hasIdToken: !!sessionData.idToken,
|
hasIdToken: !!sessionData.idToken,
|
||||||
|
|
@ -25,19 +36,25 @@ export default defineEventHandler(async (event) => {
|
||||||
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
||||||
})
|
})
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[SESSION] Failed to parse session cookie:', parseError)
|
console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError)
|
||||||
// Clear invalid session
|
// Clear invalid session
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'INVALID_SESSION_FORMAT',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session structure
|
// Validate session structure
|
||||||
if (!sessionData.user || !sessionData.accessToken) {
|
if (!sessionData.user || !sessionData.accessToken) {
|
||||||
console.error('[SESSION] Invalid session structure:', {
|
console.error(`[SESSION:${requestId}] Invalid session structure:`, {
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
hasAccessToken: !!sessionData.accessToken
|
hasAccessToken: !!sessionData.accessToken
|
||||||
})
|
})
|
||||||
|
|
@ -46,12 +63,18 @@ export default defineEventHandler(async (event) => {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'INVALID_SESSION_STRUCTURE',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session is still valid
|
// Check if session is still valid
|
||||||
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
||||||
console.log('[SESSION] Session expired:', {
|
console.log(`[SESSION:${requestId}] Session expired:`, {
|
||||||
expiresAt: sessionData.expiresAt,
|
expiresAt: sessionData.expiresAt,
|
||||||
currentTime: Date.now(),
|
currentTime: Date.now(),
|
||||||
expiredSince: Date.now() - sessionData.expiresAt
|
expiredSince: Date.now() - sessionData.expiresAt
|
||||||
|
|
@ -62,7 +85,13 @@ export default defineEventHandler(async (event) => {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'SESSION_EXPIRED',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract groups from ID token
|
// Extract groups from ID token
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ export default defineEventHandler(async (event) => {
|
||||||
await requireAuth(event);
|
await requireAuth(event);
|
||||||
|
|
||||||
console.log('[Delete Generated EOI] Request received');
|
console.log('[Delete Generated EOI] Request received');
|
||||||
|
console.log('[Delete Generated EOI] Request headers:', getHeaders(event));
|
||||||
|
console.log('[Delete Generated EOI] Request method:', getMethod(event));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
|
|
@ -15,12 +17,13 @@ export default defineEventHandler(async (event) => {
|
||||||
const query = getQuery(event);
|
const query = getQuery(event);
|
||||||
|
|
||||||
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
||||||
|
console.log('[Delete Generated EOI] Query params:', query);
|
||||||
|
|
||||||
if (!interestId) {
|
if (!interestId) {
|
||||||
console.error('[Delete Generated EOI] No interest ID provided');
|
console.error('[Delete Generated EOI] No interest ID provided');
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: 'Interest ID is required',
|
statusMessage: 'Interest ID is required. Please provide a valid interest ID.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
||||||
import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
|
import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
|
||||||
|
import { findDuplicates, createExpenseConfig } from '~/server/utils/duplicate-detection';
|
||||||
import type { Expense } from '~/utils/types';
|
import type { Expense } from '~/utils/types';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -35,21 +36,31 @@ export default defineEventHandler(async (event) => {
|
||||||
const expenses = response.list || [];
|
const expenses = response.list || [];
|
||||||
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
|
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
|
||||||
|
|
||||||
// Find duplicate groups
|
// Find duplicate groups using the new centralized utility
|
||||||
const duplicateGroups = findDuplicateExpenses(expenses);
|
const duplicateConfig = createExpenseConfig();
|
||||||
|
const duplicateGroups = findDuplicates(expenses, duplicateConfig);
|
||||||
|
|
||||||
|
// Convert to the expected format
|
||||||
|
const formattedGroups = duplicateGroups.map(group => ({
|
||||||
|
id: group.id,
|
||||||
|
expenses: group.items,
|
||||||
|
matchReason: group.matchReason,
|
||||||
|
confidence: group.confidence,
|
||||||
|
masterCandidate: group.masterCandidate
|
||||||
|
}));
|
||||||
|
|
||||||
// Also find payer name variations
|
// Also find payer name variations
|
||||||
const payerVariations = findPayerNameVariations(expenses);
|
const payerVariations = findPayerNameVariations(expenses);
|
||||||
|
|
||||||
console.log('[EXPENSES] Found', duplicateGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations');
|
console.log('[EXPENSES] Found', formattedGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
duplicateGroups,
|
duplicateGroups: formattedGroups,
|
||||||
payerVariations,
|
payerVariations,
|
||||||
totalExpenses: expenses.length,
|
totalExpenses: expenses.length,
|
||||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: startDate.toISOString().split('T')[0],
|
start: startDate.toISOString().split('T')[0],
|
||||||
end: endDate.toISOString().split('T')[0]
|
end: endDate.toISOString().split('T')[0]
|
||||||
|
|
@ -74,71 +85,6 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Find duplicate expenses based on multiple criteria
|
|
||||||
*/
|
|
||||||
function findDuplicateExpenses(expenses: any[]) {
|
|
||||||
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
|
|
||||||
|
|
||||||
const duplicateGroups: Array<{
|
|
||||||
id: string;
|
|
||||||
expenses: any[];
|
|
||||||
matchReason: string;
|
|
||||||
confidence: number;
|
|
||||||
masterCandidate: any;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const processedIds = new Set<number>();
|
|
||||||
let comparisons = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < expenses.length; i++) {
|
|
||||||
const expense1 = expenses[i];
|
|
||||||
|
|
||||||
if (processedIds.has(expense1.Id)) continue;
|
|
||||||
|
|
||||||
const matches = [expense1];
|
|
||||||
let matchReasons = new Set<string>();
|
|
||||||
|
|
||||||
for (let j = i + 1; j < expenses.length; j++) {
|
|
||||||
const expense2 = expenses[j];
|
|
||||||
|
|
||||||
if (processedIds.has(expense2.Id)) continue;
|
|
||||||
|
|
||||||
const similarity = calculateExpenseSimilarity(expense1, expense2);
|
|
||||||
comparisons++;
|
|
||||||
|
|
||||||
console.log(`[EXPENSES] Comparing ${expense1.Id} vs ${expense2.Id}: score=${similarity.score.toFixed(3)}, threshold=0.7`);
|
|
||||||
|
|
||||||
if (similarity.score >= 0.7) { // Lower threshold for expenses
|
|
||||||
console.log(`[EXPENSES] MATCH FOUND! ${expense1.Id} vs ${expense2.Id} (score: ${similarity.score.toFixed(3)})`);
|
|
||||||
console.log('[EXPENSES] Match reasons:', similarity.reasons);
|
|
||||||
matches.push(expense2);
|
|
||||||
processedIds.add(expense2.Id);
|
|
||||||
similarity.reasons.forEach(r => matchReasons.add(r));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length > 1) {
|
|
||||||
// Mark all as processed
|
|
||||||
matches.forEach(match => processedIds.add(match.Id));
|
|
||||||
|
|
||||||
// Determine the best master candidate
|
|
||||||
const masterCandidate = selectMasterExpense(matches);
|
|
||||||
|
|
||||||
duplicateGroups.push({
|
|
||||||
id: `group_${duplicateGroups.length + 1}`,
|
|
||||||
expenses: matches,
|
|
||||||
matchReason: Array.from(matchReasons).join(', '),
|
|
||||||
confidence: Math.max(...matches.slice(1).map(match =>
|
|
||||||
calculateExpenseSimilarity(masterCandidate, match).score
|
|
||||||
)),
|
|
||||||
masterCandidate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return duplicateGroups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find payer name variations (like "Abbie" vs "abbie")
|
* Find payer name variations (like "Abbie" vs "abbie")
|
||||||
|
|
@ -181,154 +127,3 @@ function findPayerNameVariations(expenses: any[]) {
|
||||||
|
|
||||||
return variations.sort((a, b) => b.expenseCount - a.expenseCount);
|
return variations.sort((a, b) => b.expenseCount - a.expenseCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two expenses
|
|
||||||
*/
|
|
||||||
function calculateExpenseSimilarity(expense1: any, expense2: any) {
|
|
||||||
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
|
||||||
const reasons: string[] = [];
|
|
||||||
|
|
||||||
// Exact match on establishment, price, and date (highest weight for true duplicates)
|
|
||||||
if (expense1['Establishment Name'] === expense2['Establishment Name'] &&
|
|
||||||
expense1.Price === expense2.Price &&
|
|
||||||
expense1.Time === expense2.Time) {
|
|
||||||
scores.push({ type: 'exact', score: 1.0, weight: 0.5 });
|
|
||||||
reasons.push('Exact match');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Same payer, establishment, and price on same day (likely duplicate)
|
|
||||||
const date1 = expense1.Time?.split('T')[0];
|
|
||||||
const date2 = expense2.Time?.split('T')[0];
|
|
||||||
|
|
||||||
if (normalizePersonName(expense1.Payer) === normalizePersonName(expense2.Payer) &&
|
|
||||||
expense1['Establishment Name'] === expense2['Establishment Name'] &&
|
|
||||||
expense1.Price === expense2.Price &&
|
|
||||||
date1 === date2) {
|
|
||||||
scores.push({ type: 'same-day', score: 0.95, weight: 0.4 });
|
|
||||||
reasons.push('Same person, place, amount on same day');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Similar establishment names with same price and payer
|
|
||||||
if (expense1['Establishment Name'] && expense2['Establishment Name']) {
|
|
||||||
const nameSimilarity = calculateStringSimilarity(
|
|
||||||
expense1['Establishment Name'],
|
|
||||||
expense2['Establishment Name']
|
|
||||||
);
|
|
||||||
|
|
||||||
if (nameSimilarity > 0.8 &&
|
|
||||||
expense1.Price === expense2.Price &&
|
|
||||||
normalizePersonName(expense1.Payer) === normalizePersonName(expense2.Payer)) {
|
|
||||||
scores.push({ type: 'similar', score: nameSimilarity, weight: 0.3 });
|
|
||||||
reasons.push('Similar establishment name');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Time proximity check (within 5 minutes)
|
|
||||||
if (expense1.Time && expense2.Time) {
|
|
||||||
const time1 = new Date(expense1.Time).getTime();
|
|
||||||
const time2 = new Date(expense2.Time).getTime();
|
|
||||||
const timeDiff = Math.abs(time1 - time2);
|
|
||||||
|
|
||||||
if (timeDiff < 5 * 60 * 1000 && // 5 minutes
|
|
||||||
expense1['Establishment Name'] === expense2['Establishment Name']) {
|
|
||||||
scores.push({ type: 'time-proximity', score: 0.9, weight: 0.2 });
|
|
||||||
reasons.push('Within 5 minutes at same establishment');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate weighted average
|
|
||||||
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
|
||||||
const weightedScore = totalWeight > 0
|
|
||||||
? scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / totalWeight
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: weightedScore,
|
|
||||||
reasons,
|
|
||||||
details: scores
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate string similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateStringSimilarity(str1: string, str2: string): number {
|
|
||||||
const s1 = str1.toLowerCase().trim();
|
|
||||||
const s2 = str2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (s1 === s2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(s1, s2);
|
|
||||||
const maxLength = Math.max(s1.length, s2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Levenshtein distance between two strings
|
|
||||||
*/
|
|
||||||
function levenshteinDistance(str1: string, str2: string): number {
|
|
||||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i <= str1.length; i += 1) {
|
|
||||||
matrix[0][i] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 0; j <= str2.length; j += 1) {
|
|
||||||
matrix[j][0] = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 1; j <= str2.length; j += 1) {
|
|
||||||
for (let i = 1; i <= str1.length; i += 1) {
|
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
||||||
matrix[j][i] = Math.min(
|
|
||||||
matrix[j][i - 1] + 1, // deletion
|
|
||||||
matrix[j - 1][i] + 1, // insertion
|
|
||||||
matrix[j - 1][i - 1] + indicator // substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix[str2.length][str1.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the best master expense from a group
|
|
||||||
*/
|
|
||||||
function selectMasterExpense(expenses: any[]) {
|
|
||||||
return expenses.reduce((best, current) => {
|
|
||||||
const bestScore = calculateExpenseCompletenessScore(best);
|
|
||||||
const currentScore = calculateExpenseCompletenessScore(current);
|
|
||||||
|
|
||||||
return currentScore > bestScore ? current : best;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate completeness score for an expense
|
|
||||||
*/
|
|
||||||
function calculateExpenseCompletenessScore(expense: any): number {
|
|
||||||
const fields = ['Establishment Name', 'Price', 'Payer', 'Category', 'Contents', 'Time'];
|
|
||||||
const filledFields = fields.filter(field =>
|
|
||||||
expense[field] && expense[field].toString().trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
let score = filledFields.length / fields.length;
|
|
||||||
|
|
||||||
// Bonus for having contents description
|
|
||||||
if (expense.Contents && expense.Contents.length > 10) {
|
|
||||||
score += 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bonus for recent creation (more likely to be accurate)
|
|
||||||
if (expense.CreatedAt) {
|
|
||||||
const created = new Date(expense.CreatedAt);
|
|
||||||
const now = new Date();
|
|
||||||
const hoursOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60);
|
|
||||||
|
|
||||||
if (hoursOld < 24) score += 0.1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.min(score, 1.0);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -748,56 +748,22 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||||
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
|
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Ensure URL is properly encoded
|
// Use the signed URL directly without modification to preserve AWS signature
|
||||||
let encodedUrl = rawPath;
|
console.log('[expenses/generate-pdf] Fetching from S3 URL (preserving signature):', rawPath);
|
||||||
try {
|
|
||||||
// Parse and reconstruct URL to ensure proper encoding
|
|
||||||
const url = new URL(rawPath);
|
|
||||||
// Re-encode the pathname to handle special characters
|
|
||||||
url.pathname = url.pathname.split('/').map(segment => encodeURIComponent(decodeURIComponent(segment))).join('/');
|
|
||||||
encodedUrl = url.toString();
|
|
||||||
console.log('[expenses/generate-pdf] URL encoded:', encodedUrl);
|
|
||||||
} catch (urlError) {
|
|
||||||
console.log('[expenses/generate-pdf] Using original URL (encoding failed):', rawPath);
|
|
||||||
encodedUrl = rawPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch image directly from S3 URL with proper headers
|
// Fetch image directly from S3 URL with minimal headers to avoid signature issues
|
||||||
const response = await fetch(encodedUrl, {
|
const response = await fetch(rawPath, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'image/*',
|
'Accept': 'image/*'
|
||||||
'User-Agent': 'PortNimara-Client-Portal/1.0',
|
|
||||||
'Cache-Control': 'no-cache'
|
|
||||||
},
|
},
|
||||||
// Add timeout to prevent hanging
|
// Add timeout to prevent hanging
|
||||||
signal: AbortSignal.timeout(45000) // 45 second timeout
|
signal: AbortSignal.timeout(30000) // 30 second timeout
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
|
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
|
||||||
console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries()));
|
console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries()));
|
||||||
|
|
||||||
// Try with the original URL if encoding failed
|
|
||||||
if (encodedUrl !== rawPath) {
|
|
||||||
console.log('[expenses/generate-pdf] Retrying with original URL...');
|
|
||||||
const originalResponse = await fetch(rawPath, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'image/*',
|
|
||||||
'User-Agent': 'PortNimara-Client-Portal/1.0'
|
|
||||||
},
|
|
||||||
signal: AbortSignal.timeout(30000)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (originalResponse.ok) {
|
|
||||||
const arrayBuffer = await originalResponse.arrayBuffer();
|
|
||||||
const imageBuffer = Buffer.from(arrayBuffer);
|
|
||||||
console.log('[expenses/generate-pdf] Successfully fetched with original URL, Size:', imageBuffer.length);
|
|
||||||
return imageBuffer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -810,27 +776,13 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
||||||
|
|
||||||
} catch (fetchError: any) {
|
} catch (fetchError: any) {
|
||||||
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
|
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
|
||||||
|
console.error('[expenses/generate-pdf] Error details:', {
|
||||||
// If it's a timeout or network error, try one more time with simpler approach
|
name: fetchError.name,
|
||||||
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError' || fetchError.code === 'ECONNRESET') {
|
code: fetchError.code,
|
||||||
console.log('[expenses/generate-pdf] Network error, trying simplified approach...');
|
message: fetchError.message
|
||||||
try {
|
|
||||||
const simpleResponse = await fetch(rawPath, {
|
|
||||||
method: 'GET',
|
|
||||||
signal: AbortSignal.timeout(90000) // Extended timeout for final attempt
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (simpleResponse.ok) {
|
// Don't try multiple attempts for signed URLs as they may expire
|
||||||
const arrayBuffer = await simpleResponse.arrayBuffer();
|
|
||||||
const imageBuffer = Buffer.from(arrayBuffer);
|
|
||||||
console.log('[expenses/generate-pdf] Successfully fetched image with simplified approach, Size:', imageBuffer.length);
|
|
||||||
return imageBuffer;
|
|
||||||
}
|
|
||||||
} catch (finalError) {
|
|
||||||
console.error('[expenses/generate-pdf] Final attempt also failed:', finalError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,43 @@ import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb';
|
||||||
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||||
import type { ExpenseFilters } from '@/utils/types';
|
import type { ExpenseFilters } from '@/utils/types';
|
||||||
|
|
||||||
|
// Retry operation wrapper for database calls
|
||||||
|
async function retryOperation<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
baseDelay: number = 1000
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`[get-expenses] Attempt ${attempt}/${maxRetries} failed:`, error.message);
|
||||||
|
|
||||||
|
// Don't retry on authentication/authorization errors
|
||||||
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry on client errors (4xx except 404)
|
||||||
|
if (error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the last attempt, throw the error
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For retryable errors (5xx, network errors, timeouts), wait before retry
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
|
||||||
|
console.log(`[get-expenses] Retrying in ${delay}ms...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Retry operation failed unexpectedly');
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[get-expenses] API called with query:', getQuery(event));
|
console.log('[get-expenses] API called with query:', getQuery(event));
|
||||||
|
|
||||||
|
|
@ -10,6 +47,7 @@ export default defineEventHandler(async (event) => {
|
||||||
// Set proper headers
|
// Set proper headers
|
||||||
setHeader(event, 'Cache-Control', 'no-cache');
|
setHeader(event, 'Cache-Control', 'no-cache');
|
||||||
setHeader(event, 'Content-Type', 'application/json');
|
setHeader(event, 'Content-Type', 'application/json');
|
||||||
|
|
||||||
// Check authentication first
|
// Check authentication first
|
||||||
try {
|
try {
|
||||||
await requireSalesOrAdmin(event);
|
await requireSalesOrAdmin(event);
|
||||||
|
|
@ -36,7 +74,7 @@ export default defineEventHandler(async (event) => {
|
||||||
console.log('[get-expenses] No date filters provided, defaulting to current month');
|
console.log('[get-expenses] No date filters provided, defaulting to current month');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getCurrentMonthExpenses();
|
const result = await retryOperation(() => getCurrentMonthExpenses());
|
||||||
|
|
||||||
// Process expenses with currency conversion
|
// Process expenses with currency conversion
|
||||||
const processedExpenses = await Promise.all(
|
const processedExpenses = await Promise.all(
|
||||||
|
|
@ -57,6 +95,13 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dbError.statusCode === 404) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'No expense records found for the current month.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||||
|
|
@ -86,7 +131,7 @@ export default defineEventHandler(async (event) => {
|
||||||
console.log('[get-expenses] Fetching expenses with filters:', filters);
|
console.log('[get-expenses] Fetching expenses with filters:', filters);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getExpenses(filters);
|
const result = await retryOperation(() => getExpenses(filters));
|
||||||
|
|
||||||
// Process expenses with currency conversion
|
// Process expenses with currency conversion
|
||||||
const processedExpenses = await Promise.all(
|
const processedExpenses = await Promise.all(
|
||||||
|
|
@ -126,6 +171,13 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dbError.statusCode === 404) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'No expense records found matching the specified criteria.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
||||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||||
import { logAuditEvent } from '~/server/utils/audit-logger';
|
import { logAuditEvent } from '~/server/utils/audit-logger';
|
||||||
|
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[INTERESTS] Find duplicates request');
|
console.log('[INTERESTS] Find duplicates request');
|
||||||
|
|
@ -19,11 +20,12 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
let url = `${config.url}/api/v2/tables/${interestTableId}/records`;
|
let url = `${config.url}/api/v2/tables/${interestTableId}/records`;
|
||||||
|
|
||||||
// Add date filtering if specified
|
// Add date filtering if specified (include records without Created At)
|
||||||
if (dateRange && dateRange > 0) {
|
if (dateRange && dateRange > 0) {
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - dateRange);
|
cutoffDate.setDate(cutoffDate.getDate() - dateRange);
|
||||||
const dateFilter = `(Created At,gte,${cutoffDate.toISOString()})`;
|
// Include records without Created At OR within date range
|
||||||
|
const dateFilter = `((Created At,gte,${cutoffDate.toISOString()}),or,(Created At,is,null))`;
|
||||||
url += `?where=${encodeURIComponent(dateFilter)}`;
|
url += `?where=${encodeURIComponent(dateFilter)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,16 +41,26 @@ export default defineEventHandler(async (event) => {
|
||||||
const interests = response.list || [];
|
const interests = response.list || [];
|
||||||
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
|
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
|
||||||
|
|
||||||
// Find potential duplicates
|
// Find duplicate groups using the new centralized utility
|
||||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
const duplicateConfig = createInterestConfig();
|
||||||
|
const duplicateGroups = findDuplicates(interests, duplicateConfig);
|
||||||
|
|
||||||
console.log('[INTERESTS] Found', duplicateGroups.length, 'duplicate groups');
|
// Convert to the expected format
|
||||||
|
const formattedGroups = duplicateGroups.map(group => ({
|
||||||
|
id: group.id,
|
||||||
|
interests: group.items,
|
||||||
|
matchReason: group.matchReason,
|
||||||
|
confidence: group.confidence,
|
||||||
|
masterCandidate: group.masterCandidate
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('[INTERESTS] Found', formattedGroups.length, 'duplicate groups');
|
||||||
|
|
||||||
// Log the audit event
|
// Log the audit event
|
||||||
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
|
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
|
||||||
changes: {
|
changes: {
|
||||||
totalInterests: interests.length,
|
totalInterests: interests.length,
|
||||||
duplicateGroups: duplicateGroups.length,
|
duplicateGroups: formattedGroups.length,
|
||||||
threshold,
|
threshold,
|
||||||
dateRange
|
dateRange
|
||||||
}
|
}
|
||||||
|
|
@ -57,9 +69,9 @@ export default defineEventHandler(async (event) => {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
duplicateGroups,
|
duplicateGroups: formattedGroups,
|
||||||
totalInterests: interests.length,
|
totalInterests: interests.length,
|
||||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||||
threshold,
|
threshold,
|
||||||
dateRange
|
dateRange
|
||||||
}
|
}
|
||||||
|
|
@ -81,288 +93,3 @@ export default defineEventHandler(async (event) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Find duplicate interests based on multiple criteria
|
|
||||||
*/
|
|
||||||
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|
||||||
console.log('[INTERESTS] Starting duplicate detection with threshold:', threshold);
|
|
||||||
console.log('[INTERESTS] Total interests to analyze:', interests.length);
|
|
||||||
|
|
||||||
const duplicateGroups: Array<{
|
|
||||||
id: string;
|
|
||||||
interests: any[];
|
|
||||||
matchReason: string;
|
|
||||||
confidence: number;
|
|
||||||
masterCandidate: any;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const processedIds = new Set<number>();
|
|
||||||
let comparisons = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < interests.length; i++) {
|
|
||||||
const interest1 = interests[i];
|
|
||||||
|
|
||||||
if (processedIds.has(interest1.Id)) continue;
|
|
||||||
|
|
||||||
const matches = [interest1];
|
|
||||||
|
|
||||||
for (let j = i + 1; j < interests.length; j++) {
|
|
||||||
const interest2 = interests[j];
|
|
||||||
|
|
||||||
if (processedIds.has(interest2.Id)) continue;
|
|
||||||
|
|
||||||
const similarity = calculateSimilarity(interest1, interest2);
|
|
||||||
comparisons++;
|
|
||||||
|
|
||||||
console.log(`[INTERESTS] Comparing ${interest1.Id} vs ${interest2.Id}: score=${similarity.score.toFixed(3)}, threshold=${threshold}`);
|
|
||||||
|
|
||||||
if (similarity.score >= threshold) {
|
|
||||||
console.log(`[INTERESTS] MATCH FOUND! ${interest1.Id} vs ${interest2.Id} (score: ${similarity.score.toFixed(3)})`);
|
|
||||||
console.log('[INTERESTS] Match details:', similarity.details);
|
|
||||||
matches.push(interest2);
|
|
||||||
processedIds.add(interest2.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length > 1) {
|
|
||||||
console.log(`[INTERESTS] Creating duplicate group with ${matches.length} matches`);
|
|
||||||
|
|
||||||
// Mark all as processed
|
|
||||||
matches.forEach(match => processedIds.add(match.Id));
|
|
||||||
|
|
||||||
// Determine the best master candidate (most complete record)
|
|
||||||
const masterCandidate = selectMasterCandidate(matches);
|
|
||||||
|
|
||||||
// Calculate average confidence
|
|
||||||
const avgConfidence = matches.slice(1).reduce((sum, match) => {
|
|
||||||
return sum + calculateSimilarity(masterCandidate, match).score;
|
|
||||||
}, 0) / (matches.length - 1);
|
|
||||||
|
|
||||||
duplicateGroups.push({
|
|
||||||
id: `group_${duplicateGroups.length + 1}`,
|
|
||||||
interests: matches,
|
|
||||||
matchReason: generateMatchReason(matches),
|
|
||||||
confidence: avgConfidence,
|
|
||||||
masterCandidate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[INTERESTS] Completed ${comparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
|
|
||||||
return duplicateGroups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two interests
|
|
||||||
*/
|
|
||||||
function calculateSimilarity(interest1: any, interest2: any) {
|
|
||||||
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
|
||||||
|
|
||||||
console.log(`[INTERESTS] Calculating similarity between:`, {
|
|
||||||
id1: interest1.Id,
|
|
||||||
name1: interest1['Full Name'],
|
|
||||||
email1: interest1['Email Address'],
|
|
||||||
phone1: interest1['Phone Number'],
|
|
||||||
id2: interest2.Id,
|
|
||||||
name2: interest2['Full Name'],
|
|
||||||
email2: interest2['Email Address'],
|
|
||||||
phone2: interest2['Phone Number']
|
|
||||||
});
|
|
||||||
|
|
||||||
// Email similarity (highest weight) - exact match required
|
|
||||||
if (interest1['Email Address'] && interest2['Email Address']) {
|
|
||||||
const email1 = normalizeEmail(interest1['Email Address']);
|
|
||||||
const email2 = normalizeEmail(interest2['Email Address']);
|
|
||||||
const emailScore = email1 === email2 ? 1.0 : 0.0;
|
|
||||||
scores.push({ type: 'email', score: emailScore, weight: 0.5 });
|
|
||||||
console.log(`[INTERESTS] Email comparison: "${email1}" vs "${email2}" = ${emailScore}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phone similarity - exact match on normalized numbers
|
|
||||||
if (interest1['Phone Number'] && interest2['Phone Number']) {
|
|
||||||
const phone1 = normalizePhone(interest1['Phone Number']);
|
|
||||||
const phone2 = normalizePhone(interest2['Phone Number']);
|
|
||||||
const phoneScore = phone1 === phone2 && phone1.length >= 8 ? 1.0 : 0.0; // Require at least 8 digits
|
|
||||||
scores.push({ type: 'phone', score: phoneScore, weight: 0.4 });
|
|
||||||
console.log(`[INTERESTS] Phone comparison: "${phone1}" vs "${phone2}" = ${phoneScore}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name similarity - fuzzy matching
|
|
||||||
if (interest1['Full Name'] && interest2['Full Name']) {
|
|
||||||
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
|
|
||||||
scores.push({ type: 'name', score: nameScore, weight: 0.3 });
|
|
||||||
console.log(`[INTERESTS] Name comparison: "${interest1['Full Name']}" vs "${interest2['Full Name']}" = ${nameScore.toFixed(3)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address similarity
|
|
||||||
if (interest1.Address && interest2.Address) {
|
|
||||||
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
|
|
||||||
scores.push({ type: 'address', score: addressScore, weight: 0.2 });
|
|
||||||
console.log(`[INTERESTS] Address comparison: ${addressScore.toFixed(3)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Special case: if we have exact email OR phone match, give high score regardless of other fields
|
|
||||||
const hasExactEmailMatch = scores.find(s => s.type === 'email' && s.score === 1.0);
|
|
||||||
const hasExactPhoneMatch = scores.find(s => s.type === 'phone' && s.score === 1.0);
|
|
||||||
|
|
||||||
if (hasExactEmailMatch || hasExactPhoneMatch) {
|
|
||||||
console.log('[INTERESTS] Exact email or phone match found - high confidence');
|
|
||||||
return {
|
|
||||||
score: 0.95, // High confidence for exact email/phone match
|
|
||||||
details: scores
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate weighted average for other cases
|
|
||||||
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
|
||||||
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
|
|
||||||
|
|
||||||
console.log(`[INTERESTS] Weighted score: ${weightedScore.toFixed(3)} (weights: ${totalWeight})`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: weightedScore,
|
|
||||||
details: scores
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize email for comparison
|
|
||||||
*/
|
|
||||||
function normalizeEmail(email: string): string {
|
|
||||||
return email.toLowerCase().trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize phone number for comparison
|
|
||||||
*/
|
|
||||||
function normalizePhone(phone: string): string {
|
|
||||||
return phone.replace(/\D/g, ''); // Remove all non-digits
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate name similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateNameSimilarity(name1: string, name2: string): number {
|
|
||||||
const str1 = name1.toLowerCase().trim();
|
|
||||||
const str2 = name2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (str1 === str2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(str1, str2);
|
|
||||||
const maxLength = Math.max(str1.length, str2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate string similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateStringSimilarity(str1: string, str2: string): number {
|
|
||||||
const s1 = str1.toLowerCase().trim();
|
|
||||||
const s2 = str2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (s1 === s2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(s1, s2);
|
|
||||||
const maxLength = Math.max(s1.length, s2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Levenshtein distance between two strings
|
|
||||||
*/
|
|
||||||
function levenshteinDistance(str1: string, str2: string): number {
|
|
||||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i <= str1.length; i += 1) {
|
|
||||||
matrix[0][i] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 0; j <= str2.length; j += 1) {
|
|
||||||
matrix[j][0] = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 1; j <= str2.length; j += 1) {
|
|
||||||
for (let i = 1; i <= str1.length; i += 1) {
|
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
||||||
matrix[j][i] = Math.min(
|
|
||||||
matrix[j][i - 1] + 1, // deletion
|
|
||||||
matrix[j - 1][i] + 1, // insertion
|
|
||||||
matrix[j - 1][i - 1] + indicator // substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix[str2.length][str1.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the best master candidate from a group of duplicates
|
|
||||||
*/
|
|
||||||
function selectMasterCandidate(interests: any[]) {
|
|
||||||
return interests.reduce((best, current) => {
|
|
||||||
const bestScore = calculateCompletenessScore(best);
|
|
||||||
const currentScore = calculateCompletenessScore(current);
|
|
||||||
|
|
||||||
return currentScore > bestScore ? current : best;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate completeness score for an interest record
|
|
||||||
*/
|
|
||||||
function calculateCompletenessScore(interest: any): number {
|
|
||||||
const fields = ['Full Name', 'Email Address', 'Phone Number', 'Address', 'Extra Comments', 'Berth Size Desired'];
|
|
||||||
const filledFields = fields.filter(field =>
|
|
||||||
interest[field] && interest[field].toString().trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
let score = filledFields.length / fields.length;
|
|
||||||
|
|
||||||
// Bonus for recent creation
|
|
||||||
if (interest['Created At']) {
|
|
||||||
const created = new Date(interest['Created At']);
|
|
||||||
const now = new Date();
|
|
||||||
const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
// More recent records get a small bonus
|
|
||||||
if (daysOld < 30) score += 0.1;
|
|
||||||
else if (daysOld < 90) score += 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a descriptive match reason
|
|
||||||
*/
|
|
||||||
function generateMatchReason(interests: any[]): string {
|
|
||||||
const reasons = [];
|
|
||||||
|
|
||||||
// Check for exact email matches
|
|
||||||
const emails = interests.map(i => i['Email Address']).filter(Boolean);
|
|
||||||
if (emails.length > 1 && new Set(emails.map(e => normalizeEmail(e))).size === 1) {
|
|
||||||
reasons.push('Same email address');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for exact phone matches
|
|
||||||
const phones = interests.map(i => i['Phone Number']).filter(Boolean);
|
|
||||||
if (phones.length > 1 && new Set(phones.map(p => normalizePhone(p))).size === 1) {
|
|
||||||
reasons.push('Same phone number');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for similar names
|
|
||||||
const names = interests.map(i => i['Full Name']).filter(Boolean);
|
|
||||||
if (names.length > 1) {
|
|
||||||
const normalizedNames = names.map(n => n.toLowerCase().trim());
|
|
||||||
if (new Set(normalizedNames).size === 1) {
|
|
||||||
reasons.push('Same name');
|
|
||||||
} else {
|
|
||||||
reasons.push('Similar names');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return reasons.length > 0 ? reasons.join(', ') : 'Multiple matching criteria';
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
import { normalizePersonName } from './nocodb';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for duplicate detection
|
||||||
|
*/
|
||||||
|
export interface DuplicateDetectionConfig<T> {
|
||||||
|
type: 'expense' | 'interest';
|
||||||
|
|
||||||
|
// Field extractors
|
||||||
|
getKey: (item: T) => string; // Primary grouping key for blocking
|
||||||
|
getId: (item: T) => number; // Unique identifier
|
||||||
|
|
||||||
|
// Duplicate detection rules
|
||||||
|
rules: DuplicateRule<T>[];
|
||||||
|
|
||||||
|
// Performance settings
|
||||||
|
maxGroupSize?: number; // Skip groups larger than this
|
||||||
|
maxComparisons?: number; // Limit total comparisons
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rule for detecting duplicates
|
||||||
|
*/
|
||||||
|
export interface DuplicateRule<T> {
|
||||||
|
name: string;
|
||||||
|
weight: number;
|
||||||
|
check: (item1: T, item2: T) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of duplicate detection
|
||||||
|
*/
|
||||||
|
export interface DuplicateGroup<T> {
|
||||||
|
id: string;
|
||||||
|
items: T[];
|
||||||
|
matchReason: string;
|
||||||
|
confidence: number;
|
||||||
|
masterCandidate: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to find duplicates using an efficient blocking strategy
|
||||||
|
*/
|
||||||
|
export function findDuplicates<T>(
|
||||||
|
items: T[],
|
||||||
|
config: DuplicateDetectionConfig<T>
|
||||||
|
): DuplicateGroup<T>[] {
|
||||||
|
console.log(`[DUPLICATES] Starting detection for ${items.length} ${config.type}s`);
|
||||||
|
|
||||||
|
if (items.length === 0) return [];
|
||||||
|
|
||||||
|
// Phase 1: Group items by blocking key for efficient comparison
|
||||||
|
const blocks = new Map<string, T[]>();
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const key = config.getKey(item);
|
||||||
|
if (!blocks.has(key)) {
|
||||||
|
blocks.set(key, []);
|
||||||
|
}
|
||||||
|
blocks.get(key)!.push(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[DUPLICATES] Created ${blocks.size} blocks from ${items.length} items`);
|
||||||
|
|
||||||
|
// Phase 2: Find duplicates within each block
|
||||||
|
const duplicateGroups: DuplicateGroup<T>[] = [];
|
||||||
|
const processedIds = new Set<number>();
|
||||||
|
let totalComparisons = 0;
|
||||||
|
|
||||||
|
for (const [blockKey, blockItems] of blocks) {
|
||||||
|
// Skip large blocks that would be too expensive to process
|
||||||
|
if (config.maxGroupSize && blockItems.length > config.maxGroupSize) {
|
||||||
|
console.log(`[DUPLICATES] Skipping large block "${blockKey}" with ${blockItems.length} items`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip blocks with only one item
|
||||||
|
if (blockItems.length < 2) continue;
|
||||||
|
|
||||||
|
console.log(`[DUPLICATES] Processing block "${blockKey}" with ${blockItems.length} items`);
|
||||||
|
|
||||||
|
// Find duplicates within this block
|
||||||
|
for (let i = 0; i < blockItems.length; i++) {
|
||||||
|
const item1 = blockItems[i];
|
||||||
|
if (processedIds.has(config.getId(item1))) continue;
|
||||||
|
|
||||||
|
const group = [item1];
|
||||||
|
const matchedRules = new Set<string>();
|
||||||
|
|
||||||
|
for (let j = i + 1; j < blockItems.length; j++) {
|
||||||
|
const item2 = blockItems[j];
|
||||||
|
if (processedIds.has(config.getId(item2))) continue;
|
||||||
|
|
||||||
|
totalComparisons++;
|
||||||
|
|
||||||
|
// Check if items match according to any rule
|
||||||
|
const matchingRule = config.rules.find(rule => rule.check(item1, item2));
|
||||||
|
|
||||||
|
if (matchingRule) {
|
||||||
|
console.log(`[DUPLICATES] Match found: ${config.getId(item1)} vs ${config.getId(item2)} (rule: ${matchingRule.name})`);
|
||||||
|
group.push(item2);
|
||||||
|
matchedRules.add(matchingRule.name);
|
||||||
|
processedIds.add(config.getId(item2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if we've hit the comparison limit
|
||||||
|
if (config.maxComparisons && totalComparisons >= config.maxComparisons) {
|
||||||
|
console.log(`[DUPLICATES] Hit comparison limit of ${config.maxComparisons}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we found duplicates, create a group
|
||||||
|
if (group.length > 1) {
|
||||||
|
processedIds.add(config.getId(item1));
|
||||||
|
|
||||||
|
const masterCandidate = selectMasterCandidate(group, config.type);
|
||||||
|
const confidence = calculateGroupConfidence(group, config.rules);
|
||||||
|
|
||||||
|
duplicateGroups.push({
|
||||||
|
id: `group_${duplicateGroups.length + 1}`,
|
||||||
|
items: group,
|
||||||
|
matchReason: Array.from(matchedRules).join(', '),
|
||||||
|
confidence,
|
||||||
|
masterCandidate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maxComparisons && totalComparisons >= config.maxComparisons) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maxComparisons && totalComparisons >= config.maxComparisons) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[DUPLICATES] Completed ${totalComparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
|
||||||
|
return duplicateGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select the best master candidate from a group
|
||||||
|
*/
|
||||||
|
function selectMasterCandidate<T>(items: T[], type: 'expense' | 'interest'): T {
|
||||||
|
return items.reduce((best, current) => {
|
||||||
|
const bestScore = calculateCompletenessScore(best, type);
|
||||||
|
const currentScore = calculateCompletenessScore(current, type);
|
||||||
|
return currentScore > bestScore ? current : best;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate completeness score for prioritizing records
|
||||||
|
*/
|
||||||
|
function calculateCompletenessScore(item: any, type: 'expense' | 'interest'): number {
|
||||||
|
let score = 0;
|
||||||
|
let totalFields = 0;
|
||||||
|
|
||||||
|
if (type === 'expense') {
|
||||||
|
const fields = ['Establishment Name', 'Price', 'Payer', 'Category', 'Contents', 'Time'];
|
||||||
|
fields.forEach(field => {
|
||||||
|
totalFields++;
|
||||||
|
if (item[field] && item[field].toString().trim().length > 0) {
|
||||||
|
score++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bonus for detailed contents
|
||||||
|
if (item.Contents && item.Contents.length > 10) {
|
||||||
|
score += 0.5;
|
||||||
|
}
|
||||||
|
} else if (type === 'interest') {
|
||||||
|
const fields = ['Full Name', 'Email Address', 'Phone Number', 'Address', 'Extra Comments', 'Berth Size Desired'];
|
||||||
|
fields.forEach(field => {
|
||||||
|
totalFields++;
|
||||||
|
if (item[field] && item[field].toString().trim().length > 0) {
|
||||||
|
score++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bonus for recent creation
|
||||||
|
if (item['Created At'] || item.CreatedAt) {
|
||||||
|
const createdField = item['Created At'] || item.CreatedAt;
|
||||||
|
const created = new Date(createdField);
|
||||||
|
const now = new Date();
|
||||||
|
const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
|
||||||
|
if (daysOld < 30) score += 0.3;
|
||||||
|
else if (daysOld < 90) score += 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalFields > 0 ? score / totalFields : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate confidence score for a duplicate group
|
||||||
|
*/
|
||||||
|
function calculateGroupConfidence<T>(items: T[], rules: DuplicateRule<T>[]): number {
|
||||||
|
if (items.length < 2) return 0;
|
||||||
|
|
||||||
|
let totalConfidence = 0;
|
||||||
|
let comparisons = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
for (let j = i + 1; j < items.length; j++) {
|
||||||
|
const matchingRule = rules.find(rule => rule.check(items[i], items[j]));
|
||||||
|
if (matchingRule) {
|
||||||
|
totalConfidence += matchingRule.weight;
|
||||||
|
comparisons++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return comparisons > 0 ? totalConfidence / comparisons : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize email for comparison
|
||||||
|
*/
|
||||||
|
export function normalizeEmail(email: string): string {
|
||||||
|
return email.toLowerCase().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize phone number for comparison
|
||||||
|
*/
|
||||||
|
export function normalizePhone(phone: string): string {
|
||||||
|
return phone.replace(/\D/g, ''); // Remove all non-digits
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate string similarity using Levenshtein distance
|
||||||
|
*/
|
||||||
|
export function calculateStringSimilarity(str1: string, str2: string): number {
|
||||||
|
const s1 = str1.toLowerCase().trim();
|
||||||
|
const s2 = str2.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (s1 === s2) return 1.0;
|
||||||
|
|
||||||
|
const distance = levenshteinDistance(s1, s2);
|
||||||
|
const maxLength = Math.max(s1.length, s2.length);
|
||||||
|
|
||||||
|
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Levenshtein distance between two strings
|
||||||
|
*/
|
||||||
|
function levenshteinDistance(str1: string, str2: string): number {
|
||||||
|
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||||
|
|
||||||
|
for (let i = 0; i <= str1.length; i += 1) {
|
||||||
|
matrix[0][i] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 0; j <= str2.length; j += 1) {
|
||||||
|
matrix[j][0] = j;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let j = 1; j <= str2.length; j += 1) {
|
||||||
|
for (let i = 1; i <= str1.length; i += 1) {
|
||||||
|
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||||
|
matrix[j][i] = Math.min(
|
||||||
|
matrix[j][i - 1] + 1, // deletion
|
||||||
|
matrix[j - 1][i] + 1, // insertion
|
||||||
|
matrix[j - 1][i - 1] + indicator // substitution
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix[str2.length][str1.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create configuration for expense duplicate detection
|
||||||
|
*/
|
||||||
|
export function createExpenseConfig(): DuplicateDetectionConfig<any> {
|
||||||
|
return {
|
||||||
|
type: 'expense',
|
||||||
|
|
||||||
|
// Group by normalized payer name for blocking
|
||||||
|
getKey: (expense) => {
|
||||||
|
const payer = expense.Payer ? normalizePersonName(expense.Payer) : 'unknown';
|
||||||
|
const date = expense.Time ? expense.Time.split('T')[0] : 'nodate';
|
||||||
|
return `${payer}_${date}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getId: (expense) => expense.Id,
|
||||||
|
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
name: 'Exact match',
|
||||||
|
weight: 1.0,
|
||||||
|
check: (exp1, exp2) => {
|
||||||
|
return exp1['Establishment Name'] === exp2['Establishment Name'] &&
|
||||||
|
exp1.Price === exp2.Price &&
|
||||||
|
exp1.Time === exp2.Time;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Same day, same details',
|
||||||
|
weight: 0.95,
|
||||||
|
check: (exp1, exp2) => {
|
||||||
|
const date1 = exp1.Time?.split('T')[0];
|
||||||
|
const date2 = exp2.Time?.split('T')[0];
|
||||||
|
|
||||||
|
return normalizePersonName(exp1.Payer || '') === normalizePersonName(exp2.Payer || '') &&
|
||||||
|
exp1['Establishment Name'] === exp2['Establishment Name'] &&
|
||||||
|
exp1.Price === exp2.Price &&
|
||||||
|
date1 === date2;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Close time proximity',
|
||||||
|
weight: 0.9,
|
||||||
|
check: (exp1, exp2) => {
|
||||||
|
if (!exp1.Time || !exp2.Time) return false;
|
||||||
|
|
||||||
|
const time1 = new Date(exp1.Time).getTime();
|
||||||
|
const time2 = new Date(exp2.Time).getTime();
|
||||||
|
const timeDiff = Math.abs(time1 - time2);
|
||||||
|
|
||||||
|
return timeDiff < 5 * 60 * 1000 && // 5 minutes
|
||||||
|
exp1['Establishment Name'] === exp2['Establishment Name'] &&
|
||||||
|
exp1.Price === exp2.Price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
maxGroupSize: 50,
|
||||||
|
maxComparisons: 10000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create configuration for interest duplicate detection
|
||||||
|
*/
|
||||||
|
export function createInterestConfig(): DuplicateDetectionConfig<any> {
|
||||||
|
return {
|
||||||
|
type: 'interest',
|
||||||
|
|
||||||
|
// Group by normalized name prefix for blocking to catch name-based duplicates
|
||||||
|
getKey: (interest) => {
|
||||||
|
// Priority 1: Use normalized name prefix (first 3 chars) to catch name duplicates
|
||||||
|
if (interest['Full Name']) {
|
||||||
|
const name = interest['Full Name'].toLowerCase().trim();
|
||||||
|
const prefix = name.substring(0, 3);
|
||||||
|
return `name_${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Use email domain for email-based grouping
|
||||||
|
if (interest['Email Address']) {
|
||||||
|
const email = normalizeEmail(interest['Email Address']);
|
||||||
|
const domain = email.split('@')[1] || 'unknown';
|
||||||
|
return `email_${domain}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 3: Use phone prefix
|
||||||
|
if (interest['Phone Number']) {
|
||||||
|
const phone = normalizePhone(interest['Phone Number']);
|
||||||
|
const prefix = phone.length >= 4 ? phone.substring(0, 4) : phone;
|
||||||
|
return `phone_${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
},
|
||||||
|
|
||||||
|
getId: (interest) => interest.Id,
|
||||||
|
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
name: 'Same email',
|
||||||
|
weight: 1.0,
|
||||||
|
check: (int1, int2) => {
|
||||||
|
return int1['Email Address'] && int2['Email Address'] &&
|
||||||
|
normalizeEmail(int1['Email Address']) === normalizeEmail(int2['Email Address']);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Same phone',
|
||||||
|
weight: 1.0,
|
||||||
|
check: (int1, int2) => {
|
||||||
|
const phone1 = normalizePhone(int1['Phone Number'] || '');
|
||||||
|
const phone2 = normalizePhone(int2['Phone Number'] || '');
|
||||||
|
|
||||||
|
return phone1 && phone2 && phone1.length >= 8 && phone1 === phone2;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Similar name and address',
|
||||||
|
weight: 0.8,
|
||||||
|
check: (int1, int2) => {
|
||||||
|
if (!int1['Full Name'] || !int2['Full Name']) return false;
|
||||||
|
|
||||||
|
const nameSimilarity = calculateStringSimilarity(int1['Full Name'], int2['Full Name']);
|
||||||
|
|
||||||
|
if (nameSimilarity > 0.9) {
|
||||||
|
// If names are very similar, check address too
|
||||||
|
if (int1.Address && int2.Address) {
|
||||||
|
const addressSimilarity = calculateStringSimilarity(int1.Address, int2.Address);
|
||||||
|
return addressSimilarity > 0.8;
|
||||||
|
}
|
||||||
|
return true; // Similar names, no address to compare
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
maxGroupSize: 50,
|
||||||
|
maxComparisons: 10000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -184,7 +184,8 @@ class KeycloakClient {
|
||||||
|
|
||||||
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
|
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
|
||||||
|
|
||||||
return this.fetch(tokenUrl, {
|
try {
|
||||||
|
const response = await this.fetch(tokenUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
|
@ -197,8 +198,30 @@ class KeycloakClient {
|
||||||
}).toString()
|
}).toString()
|
||||||
}, {
|
}, {
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
retries: 1 // Only 1 retry for refresh operations
|
retries: 2 // Increased from 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Log successful refresh
|
||||||
|
console.log('[KEYCLOAK_CLIENT] Token refresh successful')
|
||||||
|
return response
|
||||||
|
} catch (error: any) {
|
||||||
|
// Distinguish between error types
|
||||||
|
if (error.status === 400 || error.status === 401) {
|
||||||
|
// Refresh token expired or invalid
|
||||||
|
console.error('[KEYCLOAK_CLIENT] Refresh token invalid:', error.status)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 401,
|
||||||
|
statusMessage: 'REFRESH_TOKEN_INVALID'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network or server error - might be transient
|
||||||
|
console.error('[KEYCLOAK_CLIENT] Refresh failed (transient?):', error)
|
||||||
|
throw createError({
|
||||||
|
statusCode: 503,
|
||||||
|
statusMessage: 'KEYCLOAK_TEMPORARILY_UNAVAILABLE'
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getCircuitBreakerStatus() {
|
getCircuitBreakerStatus() {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,272 @@
|
||||||
|
interface SessionCheckOptions {
|
||||||
|
nuxtApp?: any
|
||||||
|
cacheKey?: string
|
||||||
|
cacheExpiry?: number
|
||||||
|
fetchFn?: () => Promise<any>
|
||||||
|
bypassCache?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionResult {
|
||||||
|
user: any
|
||||||
|
authenticated: boolean
|
||||||
|
groups: string[]
|
||||||
|
reason?: string
|
||||||
|
fromCache?: boolean
|
||||||
|
timestamp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionCache {
|
||||||
|
result: SessionResult
|
||||||
|
timestamp: number
|
||||||
|
expiresAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized session management with request deduplication and caching
|
||||||
|
*/
|
||||||
|
class SessionManager {
|
||||||
|
private static instance: SessionManager
|
||||||
|
private sessionCheckPromise: Promise<SessionResult> | null = null
|
||||||
|
private sessionCheckLock = false
|
||||||
|
private lastCheckTime = 0
|
||||||
|
private readonly minCheckInterval = 1000 // 1 second minimum between checks
|
||||||
|
private sessionCache: Map<string, SessionCache> = new Map()
|
||||||
|
private readonly defaultCacheExpiry = 3 * 60 * 1000 // 3 minutes
|
||||||
|
private readonly gracePeriod = 5 * 60 * 1000 // 5 minutes grace period
|
||||||
|
|
||||||
|
static getInstance(): SessionManager {
|
||||||
|
if (!SessionManager.instance) {
|
||||||
|
SessionManager.instance = new SessionManager()
|
||||||
|
}
|
||||||
|
return SessionManager.instance
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check session with request deduplication and caching
|
||||||
|
*/
|
||||||
|
async checkSession(options: SessionCheckOptions = {}): Promise<SessionResult> {
|
||||||
|
const requestId = Math.random().toString(36).substring(7)
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Session check requested`)
|
||||||
|
|
||||||
|
// Use default cache key if not provided
|
||||||
|
const cacheKey = options.cacheKey || 'default'
|
||||||
|
const cacheExpiry = options.cacheExpiry || this.defaultCacheExpiry
|
||||||
|
|
||||||
|
// Check cache first (unless bypassing)
|
||||||
|
if (!options.bypassCache) {
|
||||||
|
const cached = this.getCachedSession(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Using cached session (age: ${Math.round((Date.now() - cached.timestamp) / 1000)}s)`)
|
||||||
|
return cached.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implement request deduplication
|
||||||
|
if (this.sessionCheckPromise) {
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Using in-flight session check`)
|
||||||
|
return this.sessionCheckPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent rapid successive checks
|
||||||
|
const now = Date.now()
|
||||||
|
if (now - this.lastCheckTime < this.minCheckInterval) {
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Rate limiting - using last cached result`)
|
||||||
|
const cached = this.getCachedSession(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
return cached.result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastCheckTime = now
|
||||||
|
this.sessionCheckPromise = this.performSessionCheck(options, requestId, cacheKey, cacheExpiry)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.sessionCheckPromise
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Session check completed:`, {
|
||||||
|
authenticated: result.authenticated,
|
||||||
|
reason: result.reason,
|
||||||
|
fromCache: result.fromCache
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
} finally {
|
||||||
|
this.sessionCheckPromise = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached session if valid
|
||||||
|
*/
|
||||||
|
private getCachedSession(cacheKey: string): SessionCache | null {
|
||||||
|
const cached = this.sessionCache.get(cacheKey)
|
||||||
|
if (!cached) return null
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
|
||||||
|
// Check if cache is still valid
|
||||||
|
if (now < cached.expiresAt) {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're within grace period for network issues
|
||||||
|
if (now - cached.timestamp < this.gracePeriod) {
|
||||||
|
console.log(`[SESSION_MANAGER] Cache expired but within grace period`)
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove expired cache
|
||||||
|
this.sessionCache.delete(cacheKey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform actual session check
|
||||||
|
*/
|
||||||
|
private async performSessionCheck(
|
||||||
|
options: SessionCheckOptions,
|
||||||
|
requestId: string,
|
||||||
|
cacheKey: string,
|
||||||
|
cacheExpiry: number
|
||||||
|
): Promise<SessionResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: SessionResult
|
||||||
|
|
||||||
|
if (options.fetchFn) {
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Using custom fetch function`)
|
||||||
|
result = await options.fetchFn()
|
||||||
|
} else {
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Using default session check`)
|
||||||
|
result = await this.defaultSessionCheck()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add metadata
|
||||||
|
result.timestamp = Date.now()
|
||||||
|
result.fromCache = false
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
this.cacheSessionResult(cacheKey, result, cacheExpiry)
|
||||||
|
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Session check completed in ${duration}ms`)
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[SESSION_MANAGER:${requestId}] Session check failed:`, error)
|
||||||
|
|
||||||
|
// Try to return cached result during network errors
|
||||||
|
const cached = this.getCachedSession(cacheKey)
|
||||||
|
if (cached && this.isNetworkError(error)) {
|
||||||
|
console.log(`[SESSION_MANAGER:${requestId}] Using cached result due to network error`)
|
||||||
|
return {
|
||||||
|
...cached.result,
|
||||||
|
reason: 'NETWORK_ERROR_CACHED'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return failed result
|
||||||
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: error.message || 'SESSION_CHECK_FAILED',
|
||||||
|
timestamp: Date.now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default session check implementation
|
||||||
|
*/
|
||||||
|
private async defaultSessionCheck(): Promise<SessionResult> {
|
||||||
|
// This would normally make a call to /api/auth/session
|
||||||
|
// For now, return a placeholder - this will be replaced by actual API call
|
||||||
|
throw new Error('Default session check not implemented - use fetchFn option')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache session result
|
||||||
|
*/
|
||||||
|
private cacheSessionResult(cacheKey: string, result: SessionResult, cacheExpiry: number): void {
|
||||||
|
const jitter = Math.floor(Math.random() * 10000) // 0-10 seconds jitter
|
||||||
|
const expiresAt = Date.now() + cacheExpiry + jitter
|
||||||
|
|
||||||
|
this.sessionCache.set(cacheKey, {
|
||||||
|
result,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
expiresAt
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[SESSION_MANAGER] Cached session result for ${cacheExpiry + jitter}ms`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a network error
|
||||||
|
*/
|
||||||
|
private isNetworkError(error: any): boolean {
|
||||||
|
return error.code === 'ECONNREFUSED' ||
|
||||||
|
error.code === 'ETIMEDOUT' ||
|
||||||
|
error.name === 'AbortError' ||
|
||||||
|
error.code === 'ENOTFOUND' ||
|
||||||
|
(error.status >= 500 && error.status < 600)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate session (used by auth refresh plugin)
|
||||||
|
*/
|
||||||
|
async validateSession(): Promise<SessionResult> {
|
||||||
|
return this.checkSession({
|
||||||
|
cacheKey: 'validation',
|
||||||
|
bypassCache: true, // Always fresh check for validation
|
||||||
|
fetchFn: async () => {
|
||||||
|
// This will be implemented to call the session API
|
||||||
|
const response = await fetch('/api/auth/session', {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Session validation failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached sessions
|
||||||
|
*/
|
||||||
|
clearCache(): void {
|
||||||
|
this.sessionCache.clear()
|
||||||
|
console.log('[SESSION_MANAGER] Session cache cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache statistics
|
||||||
|
*/
|
||||||
|
getCacheStats(): { entries: number; oldestEntry: number | null; newestEntry: number | null } {
|
||||||
|
const entries = this.sessionCache.size
|
||||||
|
let oldestEntry: number | null = null
|
||||||
|
let newestEntry: number | null = null
|
||||||
|
|
||||||
|
for (const cache of this.sessionCache.values()) {
|
||||||
|
if (oldestEntry === null || cache.timestamp < oldestEntry) {
|
||||||
|
oldestEntry = cache.timestamp
|
||||||
|
}
|
||||||
|
if (newestEntry === null || cache.timestamp > newestEntry) {
|
||||||
|
newestEntry = cache.timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { entries, oldestEntry, newestEntry }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export singleton instance
|
||||||
|
export const sessionManager = SessionManager.getInstance()
|
||||||
|
|
||||||
|
// Export class for testing
|
||||||
|
export { SessionManager }
|
||||||
|
|
@ -0,0 +1,268 @@
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
|
import { SessionManager } from '~/server/utils/session-manager'
|
||||||
|
|
||||||
|
describe('SessionManager', () => {
|
||||||
|
let sessionManager: SessionManager
|
||||||
|
let mockFetch: any
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionManager = SessionManager.getInstance()
|
||||||
|
sessionManager.clearCache()
|
||||||
|
mockFetch = vi.fn()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
sessionManager.clearCache()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Request Deduplication', () => {
|
||||||
|
it('should deduplicate concurrent requests', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValue(mockResponse)
|
||||||
|
|
||||||
|
// Make multiple concurrent requests
|
||||||
|
const promises = [
|
||||||
|
sessionManager.checkSession({ fetchFn: mockFetch }),
|
||||||
|
sessionManager.checkSession({ fetchFn: mockFetch }),
|
||||||
|
sessionManager.checkSession({ fetchFn: mockFetch })
|
||||||
|
]
|
||||||
|
|
||||||
|
const results = await Promise.all(promises)
|
||||||
|
|
||||||
|
// Should only call fetch once
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
expect(results.every(r => r.authenticated)).toBe(true)
|
||||||
|
expect(results.every(r => r.user.id === '123')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle failed requests and not cache errors', async () => {
|
||||||
|
const error = new Error('Network error')
|
||||||
|
mockFetch.mockRejectedValue(error)
|
||||||
|
|
||||||
|
const result = await sessionManager.checkSession({ fetchFn: mockFetch })
|
||||||
|
|
||||||
|
expect(result.authenticated).toBe(false)
|
||||||
|
expect(result.reason).toBe('Network error')
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should rate limit rapid successive requests', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValue(mockResponse)
|
||||||
|
|
||||||
|
// First request
|
||||||
|
await sessionManager.checkSession({ fetchFn: mockFetch })
|
||||||
|
|
||||||
|
// Immediate second request should use cache
|
||||||
|
const secondResult = await sessionManager.checkSession({ fetchFn: mockFetch })
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
expect(secondResult.authenticated).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Caching', () => {
|
||||||
|
it('should cache successful responses', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValue(mockResponse)
|
||||||
|
|
||||||
|
// First request
|
||||||
|
const firstResult = await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'test-cache'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Second request should use cache
|
||||||
|
const secondResult = await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'test-cache'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1)
|
||||||
|
expect(firstResult.authenticated).toBe(true)
|
||||||
|
expect(secondResult.authenticated).toBe(true)
|
||||||
|
expect(secondResult.fromCache).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should respect cache expiry', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValue(mockResponse)
|
||||||
|
|
||||||
|
// First request with very short cache expiry
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'short-cache',
|
||||||
|
cacheExpiry: 100 // 100ms
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait for cache to expire
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 150))
|
||||||
|
|
||||||
|
// Second request should make new fetch
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'short-cache',
|
||||||
|
cacheExpiry: 100
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should use grace period for network errors', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
|
// First successful request
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'grace-test'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock network error
|
||||||
|
const networkError = new Error('Network error')
|
||||||
|
networkError.code = 'ECONNREFUSED'
|
||||||
|
mockFetch.mockRejectedValue(networkError)
|
||||||
|
|
||||||
|
// Second request should use cached result due to network error
|
||||||
|
const result = await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'grace-test'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.authenticated).toBe(true)
|
||||||
|
expect(result.reason).toBe('NETWORK_ERROR_CACHED')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Session Validation', () => {
|
||||||
|
it('should validate session with fresh check', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
|
||||||
|
// Mock the fetch API
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockResponse)
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await sessionManager.validateSession()
|
||||||
|
|
||||||
|
expect(result.authenticated).toBe(true)
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/auth/session', {
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle validation failure', async () => {
|
||||||
|
// Mock failed fetch
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 401
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await sessionManager.validateSession()
|
||||||
|
|
||||||
|
expect(result.authenticated).toBe(false)
|
||||||
|
expect(result.reason).toBe('Session validation failed: 401')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Cache Management', () => {
|
||||||
|
it('should clear cache', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValue(mockResponse)
|
||||||
|
|
||||||
|
// Cache some data
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'clear-test'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Clear cache
|
||||||
|
sessionManager.clearCache()
|
||||||
|
|
||||||
|
// Next request should make fresh fetch
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'clear-test'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should provide cache statistics', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValue(mockResponse)
|
||||||
|
|
||||||
|
const initialStats = sessionManager.getCacheStats()
|
||||||
|
expect(initialStats.entries).toBe(0)
|
||||||
|
|
||||||
|
// Add some cache entries
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'stats-test-1'
|
||||||
|
})
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'stats-test-2'
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalStats = sessionManager.getCacheStats()
|
||||||
|
expect(finalStats.entries).toBe(2)
|
||||||
|
expect(finalStats.oldestEntry).toBeDefined()
|
||||||
|
expect(finalStats.newestEntry).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Error Handling', () => {
|
||||||
|
it('should identify network errors correctly', async () => {
|
||||||
|
const networkErrors = [
|
||||||
|
{ code: 'ECONNREFUSED' },
|
||||||
|
{ code: 'ETIMEDOUT' },
|
||||||
|
{ name: 'AbortError' },
|
||||||
|
{ code: 'ENOTFOUND' },
|
||||||
|
{ status: 503 }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const error of networkErrors) {
|
||||||
|
mockFetch.mockRejectedValue(error)
|
||||||
|
|
||||||
|
const result = await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: `network-error-${error.code || error.name || error.status}`
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.authenticated).toBe(false)
|
||||||
|
expect(result.reason).toBe(error.message || 'SESSION_CHECK_FAILED')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle non-network errors without grace period', async () => {
|
||||||
|
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
|
||||||
|
mockFetch.mockResolvedValueOnce(mockResponse)
|
||||||
|
|
||||||
|
// First successful request
|
||||||
|
await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'non-network-error'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Mock non-network error
|
||||||
|
const authError = new Error('Auth error')
|
||||||
|
authError.status = 401
|
||||||
|
mockFetch.mockRejectedValue(authError)
|
||||||
|
|
||||||
|
// Second request should not use cached result for auth errors
|
||||||
|
const result = await sessionManager.checkSession({
|
||||||
|
fetchFn: mockFetch,
|
||||||
|
cacheKey: 'non-network-error'
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.authenticated).toBe(false)
|
||||||
|
expect(result.reason).toBe('Auth error')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue