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.example
|
||||
nul
|
||||
|
|
|
|||
4
app.vue
4
app.vue
|
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<NuxtPwaManifest />
|
||||
<NuxtPage />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<GlobalToast />
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@
|
|||
{{ expense.DisplayPrice || expense.Price }}
|
||||
</div>
|
||||
<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 }}
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -64,12 +64,12 @@
|
|||
|
||||
<!-- Multiple receipts indicator -->
|
||||
<v-chip
|
||||
v-if="expense.Receipt.length > 1"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="receipt-count-chip"
|
||||
variant="flat"
|
||||
:color="getCategoryColor(expense.Category)"
|
||||
class="text-caption text-grey-darken-3"
|
||||
>
|
||||
+{{ expense.Receipt.length - 1 }}
|
||||
{{ expense.Category || 'Other' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -215,7 +215,7 @@
|
|||
<span class="text-caption">Delete</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
||||
variant="flat"
|
||||
|
|
@ -848,7 +848,7 @@ const handleFormSubmit = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const saveInterest = async (isAutoSave = false) => {
|
||||
const saveInterest = async (isAutoSave = false, closeAfterSave = false) => {
|
||||
if (interest.value) {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
|
|
@ -871,7 +871,11 @@ const saveInterest = async (isAutoSave = false) => {
|
|||
if (!isAutoSave) {
|
||||
toast.success("Interest saved successfully!");
|
||||
emit("save", interest.value);
|
||||
closeModal();
|
||||
|
||||
// Only close if explicitly requested
|
||||
if (closeAfterSave) {
|
||||
closeModal();
|
||||
}
|
||||
} else {
|
||||
// For auto-save, just emit save to refresh parent
|
||||
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) => {
|
||||
// Skip auth for SSR
|
||||
if (import.meta.server) return;
|
||||
|
|
@ -17,59 +19,59 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
|
||||
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 cacheKey = 'auth:session:cache';
|
||||
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
|
||||
|
||||
// Check if we have a cached session
|
||||
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');
|
||||
}
|
||||
const baseExpiry = 3 * 60 * 1000; // 3 minutes base cache
|
||||
const jitter = Math.floor(Math.random() * 10000); // 0-10 seconds jitter
|
||||
const cacheExpiry = baseExpiry + jitter; // Prevent thundering herd
|
||||
|
||||
try {
|
||||
// Check Keycloak authentication via session API with timeout and retries
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5)
|
||||
// Use SessionManager for deduped session checks
|
||||
const sessionData = await sessionManager.checkSession({
|
||||
nuxtApp,
|
||||
cacheKey,
|
||||
cacheExpiry,
|
||||
fetchFn: async () => {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const sessionData = await $fetch('/api/auth/session', {
|
||||
signal: controller.signal,
|
||||
retry: 2, // Increased retry count
|
||||
retryDelay: 1000, // Increased retry delay
|
||||
onRetry: ({ retries }: { retries: number }) => {
|
||||
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
|
||||
try {
|
||||
const result = await $fetch('/api/auth/session', {
|
||||
signal: controller.signal,
|
||||
retry: 2,
|
||||
retryDelay: 1000,
|
||||
onRetry: ({ retries }: { retries: number }) => {
|
||||
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;
|
||||
|
||||
clearTimeout(timeout);
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}) as any;
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Cache the session data
|
||||
// Store auth state for components
|
||||
if (!nuxtApp.payload.data) {
|
||||
nuxtApp.payload.data = {};
|
||||
}
|
||||
|
||||
nuxtApp.payload.data[cacheKey] = {
|
||||
...sessionData,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
// Store auth state for components
|
||||
nuxtApp.payload.data.authState = {
|
||||
user: sessionData.user,
|
||||
authenticated: sessionData.authenticated,
|
||||
|
|
@ -80,7 +82,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
authenticated: sessionData.authenticated,
|
||||
hasUser: !!sessionData.user,
|
||||
userId: sessionData.user?.id,
|
||||
groups: sessionData.groups || []
|
||||
groups: sessionData.groups || [],
|
||||
fromCache: sessionData.fromCache,
|
||||
reason: sessionData.reason
|
||||
});
|
||||
|
||||
if (sessionData.authenticated && sessionData.user) {
|
||||
|
|
@ -102,32 +106,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||
} catch (error: any) {
|
||||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||
|
||||
// If it's a network error or timeout, check if we have a recent cached session
|
||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
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();
|
||||
toast.warning('Network connectivity issue - using cached authentication');
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Show warning for cached results due to network errors
|
||||
if (error.reason === 'NETWORK_ERROR_CACHED') {
|
||||
const toast = useToast();
|
||||
toast.warning('Network connectivity issue - using cached authentication');
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
// Get current session data with groups
|
||||
const sessionData = await $fetch('/api/auth/session') as any;
|
||||
// Get auth state from authentication middleware (already cached)
|
||||
const nuxtApp = useNuxtApp();
|
||||
const authState = nuxtApp.payload?.data?.authState;
|
||||
|
||||
if (!sessionData.authenticated || !sessionData.user) {
|
||||
console.log('[AUTHORIZATION] User not authenticated, redirecting to login');
|
||||
return navigateTo('/login');
|
||||
// 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');
|
||||
return navigateTo('/login');
|
||||
}
|
||||
|
||||
// Use cached session
|
||||
authState.user = sessionCache.user;
|
||||
authState.groups = sessionCache.groups || [];
|
||||
}
|
||||
|
||||
// Get required roles for this route
|
||||
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
|
||||
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);
|
||||
|
||||
// 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(', ')}`;
|
||||
|
||||
// Redirect to dashboard instead of login since user is authenticated
|
||||
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);
|
||||
} catch (error) {
|
||||
console.error('[AUTHORIZATION] Error checking route access:', error);
|
||||
|
||||
// If session check fails, redirect to login
|
||||
return navigateTo('/login');
|
||||
// Don't automatically redirect to login on errors
|
||||
// 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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^3.2.0",
|
||||
"@pdfme/common": "^5.4.0",
|
||||
"@pdfme/generator": "^5.4.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
|
|
|
|||
|
|
@ -1,293 +1,13 @@
|
|||
<template>
|
||||
<v-app full-height>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:location="mdAndDown ? 'bottom' : undefined"
|
||||
>
|
||||
<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>
|
||||
<div>
|
||||
<!-- This page now acts as a parent route for dashboard pages -->
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
|
|||
|
||||
definePageMeta({
|
||||
middleware: ['authentication', 'authorization'],
|
||||
layout: 'dashboard-unified',
|
||||
auth: {
|
||||
roles: ['admin']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,6 +230,7 @@ import { formatTime, formatDateTime } from '@/utils/dateUtils'
|
|||
|
||||
definePageMeta({
|
||||
middleware: ['authentication', 'authorization'],
|
||||
layout: 'dashboard-unified',
|
||||
auth: {
|
||||
roles: ['admin']
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,6 +122,10 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard-unified'
|
||||
});
|
||||
|
||||
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<v-card class="mb-6">
|
||||
<v-card-text class="pa-6">
|
||||
<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-model="filters.startDate"
|
||||
type="date"
|
||||
|
|
@ -32,11 +32,11 @@
|
|||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@change="fetchExpenses"
|
||||
class="date-input-fix"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-text-field
|
||||
v-model="filters.endDate"
|
||||
type="date"
|
||||
|
|
@ -44,11 +44,11 @@
|
|||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@change="fetchExpenses"
|
||||
class="date-input-fix"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-select
|
||||
v-model="filters.category"
|
||||
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
||||
|
|
@ -57,15 +57,27 @@
|
|||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
@update:model-value="fetchExpenses"
|
||||
/>
|
||||
</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
|
||||
@click="resetToCurrentMonth"
|
||||
variant="outlined"
|
||||
size="large"
|
||||
size="default"
|
||||
class="w-100"
|
||||
>
|
||||
Current Month
|
||||
|
|
@ -187,7 +199,7 @@
|
|||
<div class="d-flex flex-wrap align-center">
|
||||
<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
|
||||
@click="exportCSV"
|
||||
:disabled="selectedExpenses.length === 0"
|
||||
|
|
@ -336,8 +348,9 @@ const ExpenseCreateModal = defineAsyncComponent(() => import('@/components/Expen
|
|||
|
||||
// Page meta
|
||||
definePageMeta({
|
||||
middleware: ['authentication'],
|
||||
layout: 'dashboard'
|
||||
middleware: ['authentication', 'authorization'],
|
||||
layout: 'dashboard-unified',
|
||||
roles: ['sales', 'admin']
|
||||
});
|
||||
|
||||
useHead({
|
||||
|
|
@ -649,4 +662,10 @@ onMounted(async () => {
|
|||
.v-tab {
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -336,6 +336,10 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard-unified'
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import FileUploader from '~/components/FileUploader.vue';
|
||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||
|
|
|
|||
|
|
@ -116,12 +116,12 @@
|
|||
</v-card-title>
|
||||
<v-divider />
|
||||
<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-for="berth in getBerthsByStatus(status.value)"
|
||||
:key="berth.Id"
|
||||
@click="handleBerthClick(berth)"
|
||||
class="berth-kanban-card"
|
||||
class="berth-kanban-card mb-4"
|
||||
:color="status.color"
|
||||
variant="tonal"
|
||||
elevation="0"
|
||||
|
|
@ -137,14 +137,24 @@
|
|||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
||||
<v-chip
|
||||
v-if="getInterestedCount(berth)"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
>
|
||||
{{ getInterestedCount(berth) }} interested
|
||||
</v-chip>
|
||||
<v-tooltip v-if="getInterestedCount(berth)" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
>
|
||||
{{ getInterestedCount(berth) }} interested
|
||||
</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>
|
||||
</v-card-text>
|
||||
</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') {
|
||||
let lastVisibilityChange = Date.now()
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
document.addEventListener('visibilitychange', async () => {
|
||||
if (!document.hidden) {
|
||||
const now = Date.now()
|
||||
const timeSinceLastCheck = now - lastVisibilityChange
|
||||
|
||||
// If tab was hidden for more than 1 minute, check auth status
|
||||
if (timeSinceLastCheck > 60000) {
|
||||
// If tab was hidden for more than 30 seconds, check auth status
|
||||
if (timeSinceLastCheck > 30000) {
|
||||
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
|
||||
checkAndScheduleRefresh()
|
||||
|
||||
// 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()
|
||||
} catch (error) {
|
||||
console.error('[AUTH_REFRESH] Failed to check session on visibility change:', error)
|
||||
await navigateTo('/login')
|
||||
}
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer)
|
||||
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 { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[DUPLICATES] Find duplicates request');
|
||||
console.log('[ADMIN] Find duplicates request');
|
||||
|
||||
try {
|
||||
// Require sales or admin access for duplicate detection
|
||||
|
|
@ -26,17 +27,27 @@ export default defineEventHandler(async (event) => {
|
|||
const interests = response.list || [];
|
||||
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
|
||||
|
||||
// Find potential duplicates
|
||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
||||
// Find duplicate groups using the new centralized utility
|
||||
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 {
|
||||
success: true,
|
||||
data: {
|
||||
duplicateGroups,
|
||||
duplicateGroups: formattedGroups,
|
||||
totalInterests: interests.length,
|
||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||
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] Session cookie set, redirecting to dashboard...')
|
||||
|
||||
// Redirect to dashboard
|
||||
await sendRedirect(event, '/dashboard')
|
||||
// Return HTML with client-side redirect for SPA compatibility
|
||||
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) {
|
||||
const duration = Date.now() - startTime
|
||||
|
|
|
|||
|
|
@ -2,14 +2,15 @@ import { keycloakClient } from '~/server/utils/keycloak-client'
|
|||
|
||||
export default defineEventHandler(async (event) => {
|
||||
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 {
|
||||
// Get current session
|
||||
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
||||
|
||||
if (!oidcSession) {
|
||||
console.error('[REFRESH] No session found')
|
||||
console.error(`[REFRESH:${requestId}] No session found`)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No session found'
|
||||
|
|
@ -20,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
sessionData = JSON.parse(oidcSession)
|
||||
} catch (parseError) {
|
||||
console.error('[REFRESH] Failed to parse session:', parseError)
|
||||
console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid session format'
|
||||
|
|
@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
// Check if we have a refresh token
|
||||
if (!sessionData.refreshToken) {
|
||||
console.error('[REFRESH] No refresh token available')
|
||||
console.error(`[REFRESH:${requestId}] No refresh token available`)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No refresh token available'
|
||||
|
|
@ -39,24 +40,48 @@ export default defineEventHandler(async (event) => {
|
|||
// Validate environment variables
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
||||
if (!clientSecret) {
|
||||
console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
|
||||
console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Authentication service misconfigured'
|
||||
})
|
||||
}
|
||||
|
||||
// Use refresh token to get new access token with retry logic
|
||||
console.log('[REFRESH] Using Keycloak client for token refresh...')
|
||||
// Use refresh token to get new access token with enhanced error handling
|
||||
console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`)
|
||||
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
|
||||
console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, {
|
||||
console.log(`[REFRESH:${requestId}] Token refresh successful in ${refreshDuration}ms:`, {
|
||||
hasAccessToken: !!tokenResponse.access_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
|
||||
const updatedSessionData = {
|
||||
...sessionData,
|
||||
|
|
@ -79,7 +104,7 @@ export default defineEventHandler(async (event) => {
|
|||
path: '/'
|
||||
})
|
||||
|
||||
console.log('[REFRESH] Session updated successfully')
|
||||
console.log(`[REFRESH:${requestId}] Session updated successfully`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -87,14 +112,17 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[REFRESH] Token refresh failed:', error)
|
||||
console.error(`[REFRESH:${requestId}] Token refresh failed:`, error)
|
||||
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
// 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';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
|
|
|
|||
|
|
@ -1,22 +1,33 @@
|
|||
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
|
||||
try {
|
||||
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
||||
|
||||
if (!oidcSessionCookie) {
|
||||
console.log('[SESSION] No OIDC session cookie found')
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
console.log(`[SESSION:${requestId}] No OIDC session cookie found`)
|
||||
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
|
||||
try {
|
||||
// Parse the session data
|
||||
const parseStart = Date.now()
|
||||
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,
|
||||
hasAccessToken: !!sessionData.accessToken,
|
||||
hasIdToken: !!sessionData.idToken,
|
||||
|
|
@ -25,19 +36,25 @@ export default defineEventHandler(async (event) => {
|
|||
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
||||
})
|
||||
} catch (parseError) {
|
||||
console.error('[SESSION] Failed to parse session cookie:', parseError)
|
||||
console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError)
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'INVALID_SESSION_FORMAT',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session structure
|
||||
if (!sessionData.user || !sessionData.accessToken) {
|
||||
console.error('[SESSION] Invalid session structure:', {
|
||||
console.error(`[SESSION:${requestId}] Invalid session structure:`, {
|
||||
hasUser: !!sessionData.user,
|
||||
hasAccessToken: !!sessionData.accessToken
|
||||
})
|
||||
|
|
@ -46,12 +63,18 @@ export default defineEventHandler(async (event) => {
|
|||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'INVALID_SESSION_STRUCTURE',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session is still valid
|
||||
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
||||
console.log('[SESSION] Session expired:', {
|
||||
console.log(`[SESSION:${requestId}] Session expired:`, {
|
||||
expiresAt: sessionData.expiresAt,
|
||||
currentTime: Date.now(),
|
||||
expiredSince: Date.now() - sessionData.expiresAt
|
||||
|
|
@ -62,7 +85,13 @@ export default defineEventHandler(async (event) => {
|
|||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'SESSION_EXPIRED',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
// Extract groups from ID token
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ export default defineEventHandler(async (event) => {
|
|||
await requireAuth(event);
|
||||
|
||||
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 {
|
||||
const body = await readBody(event);
|
||||
|
|
@ -15,12 +17,13 @@ export default defineEventHandler(async (event) => {
|
|||
const query = getQuery(event);
|
||||
|
||||
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
||||
console.log('[Delete Generated EOI] Query params:', query);
|
||||
|
||||
if (!interestId) {
|
||||
console.error('[Delete Generated EOI] No interest ID provided');
|
||||
throw createError({
|
||||
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 { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
|
||||
import { findDuplicates, createExpenseConfig } from '~/server/utils/duplicate-detection';
|
||||
import type { Expense } from '~/utils/types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
|
|
@ -35,21 +36,31 @@ export default defineEventHandler(async (event) => {
|
|||
const expenses = response.list || [];
|
||||
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
|
||||
|
||||
// Find duplicate groups
|
||||
const duplicateGroups = findDuplicateExpenses(expenses);
|
||||
// Find duplicate groups using the new centralized utility
|
||||
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
|
||||
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 {
|
||||
success: true,
|
||||
data: {
|
||||
duplicateGroups,
|
||||
duplicateGroups: formattedGroups,
|
||||
payerVariations,
|
||||
totalExpenses: expenses.length,
|
||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
||||
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
||||
dateRange: {
|
||||
start: startDate.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")
|
||||
|
|
@ -181,154 +127,3 @@ function findPayerNameVariations(expenses: any[]) {
|
|||
|
||||
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...');
|
||||
|
||||
try {
|
||||
// Ensure URL is properly encoded
|
||||
let encodedUrl = 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;
|
||||
}
|
||||
// Use the signed URL directly without modification to preserve AWS signature
|
||||
console.log('[expenses/generate-pdf] Fetching from S3 URL (preserving signature):', rawPath);
|
||||
|
||||
// Fetch image directly from S3 URL with proper headers
|
||||
const response = await fetch(encodedUrl, {
|
||||
// Fetch image directly from S3 URL with minimal headers to avoid signature issues
|
||||
const response = await fetch(rawPath, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'image/*',
|
||||
'User-Agent': 'PortNimara-Client-Portal/1.0',
|
||||
'Cache-Control': 'no-cache'
|
||||
'Accept': 'image/*'
|
||||
},
|
||||
// Add timeout to prevent hanging
|
||||
signal: AbortSignal.timeout(45000) // 45 second timeout
|
||||
signal: AbortSignal.timeout(30000) // 30 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
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()));
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -810,27 +776,13 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
|
|||
|
||||
} catch (fetchError: any) {
|
||||
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
|
||||
console.error('[expenses/generate-pdf] Error details:', {
|
||||
name: fetchError.name,
|
||||
code: fetchError.code,
|
||||
message: fetchError.message
|
||||
});
|
||||
|
||||
// If it's a timeout or network error, try one more time with simpler approach
|
||||
if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError' || fetchError.code === 'ECONNRESET') {
|
||||
console.log('[expenses/generate-pdf] Network error, trying simplified approach...');
|
||||
try {
|
||||
const simpleResponse = await fetch(rawPath, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(90000) // Extended timeout for final attempt
|
||||
});
|
||||
|
||||
if (simpleResponse.ok) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Don't try multiple attempts for signed URLs as they may expire
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,43 @@ import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb';
|
|||
import { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||
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) => {
|
||||
console.log('[get-expenses] API called with query:', getQuery(event));
|
||||
|
||||
|
|
@ -10,6 +47,7 @@ export default defineEventHandler(async (event) => {
|
|||
// Set proper headers
|
||||
setHeader(event, 'Cache-Control', 'no-cache');
|
||||
setHeader(event, 'Content-Type', 'application/json');
|
||||
|
||||
// Check authentication first
|
||||
try {
|
||||
await requireSalesOrAdmin(event);
|
||||
|
|
@ -36,7 +74,7 @@ export default defineEventHandler(async (event) => {
|
|||
console.log('[get-expenses] No date filters provided, defaulting to current month');
|
||||
|
||||
try {
|
||||
const result = await getCurrentMonthExpenses();
|
||||
const result = await retryOperation(() => getCurrentMonthExpenses());
|
||||
|
||||
// Process expenses with currency conversion
|
||||
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({
|
||||
statusCode: 500,
|
||||
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);
|
||||
|
||||
try {
|
||||
const result = await getExpenses(filters);
|
||||
const result = await retryOperation(() => getExpenses(filters));
|
||||
|
||||
// Process expenses with currency conversion
|
||||
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({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||
import { logAuditEvent } from '~/server/utils/audit-logger';
|
||||
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[INTERESTS] Find duplicates request');
|
||||
|
|
@ -19,11 +20,12 @@ export default defineEventHandler(async (event) => {
|
|||
|
||||
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) {
|
||||
const cutoffDate = new Date();
|
||||
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)}`;
|
||||
}
|
||||
|
||||
|
|
@ -39,16 +41,26 @@ export default defineEventHandler(async (event) => {
|
|||
const interests = response.list || [];
|
||||
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
|
||||
|
||||
// Find potential duplicates
|
||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
||||
// Find duplicate groups using the new centralized utility
|
||||
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
|
||||
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
|
||||
changes: {
|
||||
totalInterests: interests.length,
|
||||
duplicateGroups: duplicateGroups.length,
|
||||
duplicateGroups: formattedGroups.length,
|
||||
threshold,
|
||||
dateRange
|
||||
}
|
||||
|
|
@ -57,9 +69,9 @@ export default defineEventHandler(async (event) => {
|
|||
return {
|
||||
success: true,
|
||||
data: {
|
||||
duplicateGroups,
|
||||
duplicateGroups: formattedGroups,
|
||||
totalInterests: interests.length,
|
||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||
threshold,
|
||||
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,21 +184,44 @@ class KeycloakClient {
|
|||
|
||||
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
|
||||
|
||||
return this.fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: 'client-portal',
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken
|
||||
}).toString()
|
||||
}, {
|
||||
timeout: 15000,
|
||||
retries: 1 // Only 1 retry for refresh operations
|
||||
})
|
||||
try {
|
||||
const response = await this.fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: 'client-portal',
|
||||
client_secret: clientSecret,
|
||||
refresh_token: refreshToken
|
||||
}).toString()
|
||||
}, {
|
||||
timeout: 15000,
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -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