Compare commits

..

No commits in common. "bed42c7329e8a90cefc2eb11fe655607abba9bf2" and "3ba8542e4f4a2b038b309b908afba550f0294977" have entirely different histories.

36 changed files with 1342 additions and 5877 deletions

1
.gitignore vendored
View File

@ -13,4 +13,3 @@ logs
.env .env
.env.* .env.*
!.env.example !.env.example
nul

View File

@ -1,7 +1,5 @@
<template> <template>
<NuxtPwaManifest /> <NuxtPwaManifest />
<NuxtLayout>
<NuxtPage /> <NuxtPage />
</NuxtLayout>
<GlobalToast /> <GlobalToast />
</template> </template>

View File

@ -28,7 +28,7 @@
{{ expense.DisplayPrice || expense.Price }} {{ expense.DisplayPrice || expense.Price }}
</div> </div>
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info"> <div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
<span class="text-caption text-grey-darken-3"> <span class="text-caption text-grey-darken-1">
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }} Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
</span> </span>
</div> </div>

View File

@ -64,12 +64,12 @@
<!-- Multiple receipts indicator --> <!-- Multiple receipts indicator -->
<v-chip <v-chip
v-if="expense.Receipt.length > 1"
size="x-small" size="x-small"
variant="flat" color="primary"
:color="getCategoryColor(expense.Category)" class="receipt-count-chip"
class="text-caption text-grey-darken-3"
> >
{{ expense.Category || 'Other' }} +{{ expense.Receipt.length - 1 }}
</v-chip> </v-chip>
</div> </div>

View File

@ -215,7 +215,7 @@
<span class="text-caption">Delete</span> <span class="text-caption">Delete</span>
</v-btn> </v-btn>
</v-col> </v-col>
<v-col cols="12"> <v-col cols="6">
<v-btn <v-btn
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()" @click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
variant="flat" variant="flat"
@ -848,7 +848,7 @@ const handleFormSubmit = () => {
} }
}; };
const saveInterest = async (isAutoSave = false, closeAfterSave = false) => { const saveInterest = async (isAutoSave = false) => {
if (interest.value) { if (interest.value) {
isSaving.value = true; isSaving.value = true;
try { try {
@ -871,11 +871,7 @@ const saveInterest = async (isAutoSave = false, closeAfterSave = false) => {
if (!isAutoSave) { if (!isAutoSave) {
toast.success("Interest saved successfully!"); toast.success("Interest saved successfully!");
emit("save", interest.value); emit("save", interest.value);
// Only close if explicitly requested
if (closeAfterSave) {
closeModal(); closeModal();
}
} else { } else {
// For auto-save, just emit save to refresh parent // For auto-save, just emit save to refresh parent
emit("save", interest.value); emit("save", interest.value);

View File

@ -1,182 +0,0 @@
# 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.

View File

@ -1,310 +0,0 @@
# 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

View File

@ -1,321 +0,0 @@
<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>

View File

@ -1,5 +1,3 @@
import { sessionManager } from '~/server/utils/session-manager'
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
// Skip auth for SSR // Skip auth for SSR
if (import.meta.server) return; if (import.meta.server) return;
@ -19,59 +17,59 @@ export default defineNuxtRouteMiddleware(async (to) => {
console.log('[MIDDLEWARE] Checking authentication for route:', to.path); console.log('[MIDDLEWARE] Checking authentication for route:', to.path);
// Use session manager for centralized session handling // Use a cached auth state to avoid excessive API calls
const nuxtApp = useNuxtApp(); const nuxtApp = useNuxtApp();
const cacheKey = 'auth:session:cache'; const cacheKey = 'auth:session:cache';
const baseExpiry = 3 * 60 * 1000; // 3 minutes base cache const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds)
const jitter = Math.floor(Math.random() * 10000); // 0-10 seconds jitter
const cacheExpiry = baseExpiry + jitter; // Prevent thundering herd
try { // Check if we have a cached session
// Use SessionManager for deduped session checks const cachedSession = nuxtApp.payload.data?.[cacheKey];
const sessionData = await sessionManager.checkSession({ const now = Date.now();
nuxtApp,
cacheKey,
cacheExpiry,
fetchFn: async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
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;
}
}
});
if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) {
console.log('[MIDDLEWARE] Using cached session');
if (cachedSession.authenticated && cachedSession.user) {
// Store auth state for components // Store auth state for components
if (!nuxtApp.payload.data) { if (!nuxtApp.payload.data) {
nuxtApp.payload.data = {}; nuxtApp.payload.data = {};
} }
nuxtApp.payload.data.authState = {
user: cachedSession.user,
authenticated: cachedSession.authenticated,
groups: cachedSession.groups || []
};
return;
}
return navigateTo('/login');
}
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)
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})`)
}
}) as any;
clearTimeout(timeout);
// Cache the session data
if (!nuxtApp.payload.data) {
nuxtApp.payload.data = {};
}
nuxtApp.payload.data[cacheKey] = {
...sessionData,
timestamp: now
};
// Store auth state for components
nuxtApp.payload.data.authState = { nuxtApp.payload.data.authState = {
user: sessionData.user, user: sessionData.user,
authenticated: sessionData.authenticated, authenticated: sessionData.authenticated,
@ -82,9 +80,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
authenticated: sessionData.authenticated, authenticated: sessionData.authenticated,
hasUser: !!sessionData.user, hasUser: !!sessionData.user,
userId: sessionData.user?.id, userId: sessionData.user?.id,
groups: sessionData.groups || [], groups: sessionData.groups || []
fromCache: sessionData.fromCache,
reason: sessionData.reason
}); });
if (sessionData.authenticated && sessionData.user) { if (sessionData.authenticated && sessionData.user) {
@ -106,12 +102,34 @@ export default defineNuxtRouteMiddleware(async (to) => {
} catch (error: any) { } catch (error: any) {
console.error('[MIDDLEWARE] Auth check failed:', error); console.error('[MIDDLEWARE] Auth check failed:', error);
// Show warning for cached results due to network errors // If it's a network error or timeout, check if we have a recent cached session
if (error.reason === 'NETWORK_ERROR_CACHED') { 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(); const toast = useToast();
toast.warning('Network connectivity issue - using cached authentication'); toast.warning('Network connectivity issue - using cached authentication');
} }
return;
}
}
}
return navigateTo('/login'); return navigateTo('/login');
} }
}); });

View File

@ -10,29 +10,17 @@ export default defineNuxtRouteMiddleware(async (to) => {
console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles); console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles);
try { try {
// Get auth state from authentication middleware (already cached) // Get current session data with groups
const nuxtApp = useNuxtApp(); const sessionData = await $fetch('/api/auth/session') as any;
const authState = nuxtApp.payload?.data?.authState;
// If auth state not available, authentication middleware hasn't run or failed if (!sessionData.authenticated || !sessionData.user) {
if (!authState || !authState.authenticated || !authState.user) {
console.log('[AUTHORIZATION] No auth state found from authentication middleware');
// Try to get from session cache as fallback
const sessionCache = nuxtApp.payload?.data?.['auth:session:cache'];
if (!sessionCache || !sessionCache.authenticated) {
console.log('[AUTHORIZATION] User not authenticated, redirecting to login'); console.log('[AUTHORIZATION] User not authenticated, redirecting to login');
return navigateTo('/login'); return navigateTo('/login');
} }
// Use cached session
authState.user = sessionCache.user;
authState.groups = sessionCache.groups || [];
}
// Get required roles for this route // Get required roles for this route
const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles]; const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles];
const userGroups = authState.groups || []; const userGroups = sessionData.groups || [];
// Check if user has any of the required roles // Check if user has any of the required roles
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role)); const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
@ -41,20 +29,29 @@ export default defineNuxtRouteMiddleware(async (to) => {
console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles); console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
// Store the error in nuxtApp to show toast on redirect // Store the error in nuxtApp to show toast on redirect
const nuxtApp = useNuxtApp();
nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`; nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`;
// Redirect to dashboard instead of login since user is authenticated // Redirect to dashboard instead of login since user is authenticated
return navigateTo('/dashboard'); return navigateTo('/dashboard');
} }
// Store auth state in nuxtApp for use by components
const nuxtApp = useNuxtApp();
if (!nuxtApp.payload.data) {
nuxtApp.payload.data = {};
}
nuxtApp.payload.data.authState = {
user: sessionData.user,
authenticated: sessionData.authenticated,
groups: sessionData.groups || []
};
console.log('[AUTHORIZATION] Access granted for route:', to.path); console.log('[AUTHORIZATION] Access granted for route:', to.path);
} catch (error) { } catch (error) {
console.error('[AUTHORIZATION] Error checking route access:', error); console.error('[AUTHORIZATION] Error checking route access:', error);
// Don't automatically redirect to login on errors // If session check fails, redirect to login
// Let the authentication middleware handle auth failures return navigateTo('/login');
const toast = useToast();
toast.error('Failed to verify permissions. Please try again.');
return navigateTo('/dashboard');
} }
}); });

3460
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,6 @@
"postinstall": "nuxt prepare" "postinstall": "nuxt prepare"
}, },
"dependencies": { "dependencies": {
"@nuxt/ui": "^3.2.0",
"@pdfme/common": "^5.4.0", "@pdfme/common": "^5.4.0",
"@pdfme/generator": "^5.4.0", "@pdfme/generator": "^5.4.0",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",

View File

@ -1,13 +1,293 @@
<template> <template>
<div> <v-app full-height>
<!-- This page now acts as a parent route for dashboard pages --> <v-navigation-drawer
<NuxtPage /> v-model="drawer"
</div> :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>
</template> </template>
<script setup> <script setup>
definePageMeta({ definePageMeta({
middleware: ["authentication"], middleware: ["authentication"],
layout: "dashboard-unified", layout: false,
}); });
const { mdAndDown } = useDisplay();
const { user, logout, authSource } = useUnifiedAuth();
const { isAdmin, getUserGroups, getCurrentUser } = useAuthorization();
const tags = usePortalTags();
const drawer = ref(false);
// Debug auth state
onMounted(() => {
nextTick(() => {
console.log('[Dashboard] Auth state on mount:', {
isAdmin: isAdmin(),
userGroups: getUserGroups(),
currentUser: getCurrentUser()
});
});
});
const interestMenu = computed(() => {
const userIsAdmin = isAdmin();
const userGroups = getUserGroups();
console.log('[Dashboard] Computing interest menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
// Check if user has sales or admin privileges
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
const baseMenu = [
//{
// to: "/dashboard/interest-eoi-queue",
// icon: "mdi-tray-full",
// title: "EOI Queue",
//},
{
to: "/dashboard/interest-analytics",
icon: "mdi-view-dashboard",
title: "Analytics",
},
{
to: "/dashboard/interest-berth-list",
icon: "mdi-table",
title: "Berth List",
},
{
to: "/dashboard/interest-berth-status",
icon: "mdi-sail-boat",
title: "Berth Status",
},
{
to: "/dashboard/interest-list",
icon: "mdi-view-list",
title: "Interest List",
},
{
to: "/dashboard/interest-status",
icon: "mdi-account-check",
title: "Interest Status",
},
{
to: "/dashboard/file-browser",
icon: "mdi-folder",
title: "File Browser",
},
];
// Only show expenses to sales and admin users
if (hasSalesAccess) {
console.log('[Dashboard] Adding expenses to menu (user has sales/admin access)');
baseMenu.push({
to: "/dashboard/expenses",
icon: "mdi-receipt",
title: "Expenses",
});
} else {
console.log('[Dashboard] Hiding expenses from menu (user role:', userGroups, ')');
}
// Add admin menu items if user is admin
if (userIsAdmin) {
console.log('[Dashboard] Adding admin console to interest menu');
baseMenu.push({
to: "/dashboard/admin",
icon: "mdi-shield-crown",
title: "Admin Console",
});
}
return baseMenu;
});
const defaultMenu = computed(() => {
const userIsAdmin = isAdmin();
const userGroups = getUserGroups();
console.log('[Dashboard] Computing default menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
const baseMenu = [
{
to: "/dashboard/site",
icon: "mdi-view-dashboard",
title: "Site Analytics",
},
{
to: "/dashboard/data",
icon: "mdi-finance",
title: "Data Analytics",
},
{
to: "/dashboard/file-browser",
icon: "mdi-folder",
title: "File Browser",
},
];
// Add admin menu items if user is admin
if (userIsAdmin) {
console.log('[Dashboard] Adding admin console to default menu');
baseMenu.push({
to: "/dashboard/admin",
icon: "mdi-shield-crown",
title: "Admin Console",
});
}
return baseMenu;
});
const menu = computed(() => {
try {
const tagsValue = toValue(tags);
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
console.log('[Dashboard] Computing menu:', {
hasInterestTag: tagsValue.interest,
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
menuIsArray: Array.isArray(menuToUse),
menuLength: menuToUse?.length
});
return menuToUse;
} catch (error) {
console.error('[Dashboard] Error computing menu:', error);
return [];
}
});
// Safe menu wrapper to prevent crashes when menu is undefined
const safeMenu = computed(() => {
try {
const currentMenu = menu.value;
if (Array.isArray(currentMenu)) {
return currentMenu;
}
console.warn('[Dashboard] Menu is not an array, returning fallback menu');
// Get current user permissions for fallback menu
const userIsAdmin = isAdmin();
const userGroups = getUserGroups();
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
// Fallback menu with essential items (respecting permissions)
const fallbackMenu = [
{
to: "/dashboard/interest-list",
icon: "mdi-view-list",
title: "Interest List",
},
{
to: "/dashboard/file-browser",
icon: "mdi-folder",
title: "File Browser",
},
];
// Only add expenses if user has sales/admin access
if (hasSalesAccess) {
fallbackMenu.push({
to: "/dashboard/expenses",
icon: "mdi-receipt",
title: "Expenses",
});
}
// Only add admin console if user is admin
if (userIsAdmin) {
fallbackMenu.push({
to: "/dashboard/admin",
icon: "mdi-shield-crown",
title: "Admin Console",
});
}
return fallbackMenu;
} catch (error) {
console.error('[Dashboard] Error computing menu:', error);
// Emergency fallback menu - only essential items
return [
{
to: "/dashboard/interest-list",
icon: "mdi-view-list",
title: "Interest List",
},
];
}
});
const logOut = async () => {
await logout();
return navigateTo("/login");
};
onMounted(() => {
if (mdAndDown.value) {
return;
}
drawer.value = true;
})
</script> </script>

View File

@ -238,7 +238,6 @@ import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
definePageMeta({ definePageMeta({
middleware: ['authentication', 'authorization'], middleware: ['authentication', 'authorization'],
layout: 'dashboard-unified',
auth: { auth: {
roles: ['admin'] roles: ['admin']
} }

View File

@ -230,7 +230,6 @@ import { formatTime, formatDateTime } from '@/utils/dateUtils'
definePageMeta({ definePageMeta({
middleware: ['authentication', 'authorization'], middleware: ['authentication', 'authorization'],
layout: 'dashboard-unified',
auth: { auth: {
roles: ['admin'] roles: ['admin']
} }

View File

@ -122,10 +122,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({
layout: 'dashboard-unified'
});
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth(); const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
const router = useRouter(); const router = useRouter();

View File

@ -24,7 +24,7 @@
<v-card class="mb-6"> <v-card class="mb-6">
<v-card-text class="pa-6"> <v-card-text class="pa-6">
<v-row align="center" class="mb-0"> <v-row align="center" class="mb-0">
<v-col cols="12" sm="6" md="2"> <v-col cols="12" sm="6" md="3">
<v-text-field <v-text-field
v-model="filters.startDate" v-model="filters.startDate"
type="date" type="date"
@ -32,11 +32,11 @@
variant="outlined" variant="outlined"
density="comfortable" density="comfortable"
hide-details hide-details
class="date-input-fix" @change="fetchExpenses"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6" md="2"> <v-col cols="12" sm="6" md="3">
<v-text-field <v-text-field
v-model="filters.endDate" v-model="filters.endDate"
type="date" type="date"
@ -44,11 +44,11 @@
variant="outlined" variant="outlined"
density="comfortable" density="comfortable"
hide-details hide-details
class="date-input-fix" @change="fetchExpenses"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6" md="2"> <v-col cols="12" sm="6" md="3">
<v-select <v-select
v-model="filters.category" v-model="filters.category"
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']" :items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
@ -57,27 +57,15 @@
density="comfortable" density="comfortable"
hide-details hide-details
clearable clearable
@update:model-value="fetchExpenses"
/> />
</v-col> </v-col>
<v-col cols="12" sm="6" md="2"> <v-col cols="12" sm="6" md="3">
<v-btn
@click="fetchExpenses"
color="primary"
variant="flat"
size="large"
class="w-100"
prepend-icon="mdi-magnify"
>
Apply
</v-btn>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-btn <v-btn
@click="resetToCurrentMonth" @click="resetToCurrentMonth"
variant="outlined" variant="outlined"
size="default" size="large"
class="w-100" class="w-100"
> >
Current Month Current Month
@ -199,7 +187,7 @@
<div class="d-flex flex-wrap align-center"> <div class="d-flex flex-wrap align-center">
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span> <span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
<div class="d-flex ga-4"> <div class="d-flex gap-4">
<v-btn <v-btn
@click="exportCSV" @click="exportCSV"
:disabled="selectedExpenses.length === 0" :disabled="selectedExpenses.length === 0"
@ -348,9 +336,8 @@ const ExpenseCreateModal = defineAsyncComponent(() => import('@/components/Expen
// Page meta // Page meta
definePageMeta({ definePageMeta({
middleware: ['authentication', 'authorization'], middleware: ['authentication'],
layout: 'dashboard-unified', layout: 'dashboard'
roles: ['sales', 'admin']
}); });
useHead({ useHead({
@ -662,10 +649,4 @@ onMounted(async () => {
.v-tab { .v-tab {
text-transform: none !important; text-transform: none !important;
} }
/* Fix for date input calendar button positioning */
.date-input-fix :deep(.v-field__append-inner) {
padding-inline-start: 8px;
margin-inline-end: 4px;
}
</style> </style>

View File

@ -336,10 +336,6 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({
layout: 'dashboard-unified'
});
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import FileUploader from '~/components/FileUploader.vue'; import FileUploader from '~/components/FileUploader.vue';
import FilePreviewModal from '~/components/FilePreviewModal.vue'; import FilePreviewModal from '~/components/FilePreviewModal.vue';

View File

@ -116,12 +116,12 @@
</v-card-title> </v-card-title>
<v-divider /> <v-divider />
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;"> <v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
<div class="d-flex flex-column"> <div class="d-flex flex-column gap-6">
<v-card <v-card
v-for="berth in getBerthsByStatus(status.value)" v-for="berth in getBerthsByStatus(status.value)"
:key="berth.Id" :key="berth.Id"
@click="handleBerthClick(berth)" @click="handleBerthClick(berth)"
class="berth-kanban-card mb-4" class="berth-kanban-card"
:color="status.color" :color="status.color"
variant="tonal" variant="tonal"
elevation="0" elevation="0"
@ -137,24 +137,14 @@
</div> </div>
<div class="d-flex justify-space-between align-center"> <div class="d-flex justify-space-between align-center">
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span> <span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
<v-tooltip v-if="getInterestedCount(berth)" location="top">
<template v-slot:activator="{ props }">
<v-chip <v-chip
v-bind="props" v-if="getInterestedCount(berth)"
size="x-small" size="x-small"
color="primary" color="primary"
variant="flat" variant="flat"
> >
{{ getInterestedCount(berth) }} interested {{ getInterestedCount(berth) }} interested
</v-chip> </v-chip>
</template>
<div class="pa-2">
<div class="text-subtitle-2 mb-1">Interested Parties:</div>
<div v-for="party in berth['Interested Parties']" :key="party.Id" class="text-body-2">
{{ party['Full Name'] }}
</div>
</div>
</v-tooltip>
</div> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>

View File

@ -1,67 +0,0 @@
<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>

View File

@ -165,43 +165,15 @@ export default defineNuxtPlugin(() => {
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
let lastVisibilityChange = Date.now() let lastVisibilityChange = Date.now()
document.addEventListener('visibilitychange', async () => { document.addEventListener('visibilitychange', () => {
if (!document.hidden) { if (!document.hidden) {
const now = Date.now() const now = Date.now()
const timeSinceLastCheck = now - lastVisibilityChange const timeSinceLastCheck = now - lastVisibilityChange
// If tab was hidden for more than 30 seconds, check auth status // If tab was hidden for more than 1 minute, check auth status
if (timeSinceLastCheck > 30000) { if (timeSinceLastCheck > 60000) {
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status') console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
// Force immediate session validation
try {
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
if (!response.ok || response.status === 401) {
console.log('[AUTH_REFRESH] Session expired while tab was hidden')
await navigateTo('/login')
return
}
const sessionData = await response.json()
if (!sessionData.authenticated) {
console.log('[AUTH_REFRESH] Not authenticated after tab visibility')
await navigateTo('/login')
return
}
// Re-schedule refresh if session is valid
checkAndScheduleRefresh() checkAndScheduleRefresh()
} catch (error) {
console.error('[AUTH_REFRESH] Failed to check session on visibility change:', error)
await navigateTo('/login')
}
} }
lastVisibilityChange = now lastVisibilityChange = now
@ -209,71 +181,11 @@ export default defineNuxtPlugin(() => {
}) })
} }
// Add periodic session validation (every 5 minutes instead of 2) // Clean up timer on plugin destruction
let validationInterval: NodeJS.Timeout | null = null
let isValidating = false // Prevent concurrent validations
let failureCount = 0 // Track consecutive failures
onMounted(() => {
// Add random offset to prevent all clients checking at once
const randomOffset = Math.floor(Math.random() * 10000) // 0-10 seconds
setTimeout(() => {
validationInterval = setInterval(async () => {
if (isValidating) return // Skip if already validating
isValidating = true
console.log('[AUTH_REFRESH] Performing periodic session validation')
try {
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
if (!response.ok || response.status === 401) {
failureCount++
console.log(`[AUTH_REFRESH] Session check failed (attempt ${failureCount}/3)`)
// Only logout after 3 consecutive failures
if (failureCount >= 3) {
console.log('[AUTH_REFRESH] Session invalid after 3 attempts, redirecting to login')
clearInterval(validationInterval!)
await navigateTo('/login')
}
} else {
// Reset failure count on success
failureCount = 0
}
} catch (error) {
console.error('[AUTH_REFRESH] Periodic validation error:', error)
// Don't logout on network errors - let middleware handle it
// But count it as a failure for resilience
failureCount++
if (failureCount >= 3) {
console.log('[AUTH_REFRESH] Too many validation errors, redirecting to login')
clearInterval(validationInterval!)
await navigateTo('/login')
}
} finally {
isValidating = false
}
}, 5 * 60 * 1000) // Changed to 5 minutes to avoid conflicts with 3-minute cache
}, randomOffset)
})
// Clean up timers on plugin destruction
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (refreshTimer) { if (refreshTimer) {
clearTimeout(refreshTimer) clearTimeout(refreshTimer)
refreshTimer = null refreshTimer = null
} }
if (validationInterval) {
clearInterval(validationInterval)
validationInterval = null
}
}) })
}) })

View File

@ -1,129 +0,0 @@
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)
})

View File

@ -1,2 +0,0 @@
mkdir: cannot create directory C:\\Users\\mpcia\\Documents\\Cline\\MCP: File exists
hello

View File

@ -1,9 +1,8 @@
import { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth'; import { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth';
import { getNocoDbConfiguration } from '~/server/utils/nocodb'; import { getNocoDbConfiguration } from '~/server/utils/nocodb';
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
console.log('[ADMIN] Find duplicates request'); console.log('[DUPLICATES] Find duplicates request');
try { try {
// Require sales or admin access for duplicate detection // Require sales or admin access for duplicate detection
@ -27,27 +26,17 @@ export default defineEventHandler(async (event) => {
const interests = response.list || []; const interests = response.list || [];
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates'); console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
// Find duplicate groups using the new centralized utility // Find potential duplicates
const duplicateConfig = createInterestConfig(); const duplicateGroups = findDuplicateInterests(interests, threshold);
const duplicateGroups = findDuplicates(interests, duplicateConfig);
// Convert to the expected format console.log('[ADMIN] Found', duplicateGroups.length, 'duplicate groups');
const formattedGroups = duplicateGroups.map(group => ({
id: group.id,
interests: group.items,
matchReason: group.matchReason,
confidence: group.confidence,
masterCandidate: group.masterCandidate
}));
console.log('[ADMIN] Found', formattedGroups.length, 'duplicate groups');
return { return {
success: true, success: true,
data: { data: {
duplicateGroups: formattedGroups, duplicateGroups,
totalInterests: interests.length, totalInterests: interests.length,
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0), duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
threshold threshold
} }
}; };
@ -68,3 +57,203 @@ 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;
}

View File

@ -100,61 +100,8 @@ export default defineEventHandler(async (event) => {
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`) console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...') console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
// Return HTML with client-side redirect for SPA compatibility // Redirect to dashboard
setHeader(event, 'Content-Type', 'text/html') await sendRedirect(event, '/dashboard')
return `
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful - Port Nimara Portal</title>
<meta http-equiv="refresh" content="0;url=/dashboard">
<script>
// Immediate redirect
window.location.href = '/dashboard';
</script>
<style>
body {
font-family: Arial, sans-serif;
background: linear-gradient(135deg, #387bca 0%, #2c5aa0 100%);
color: white;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
text-align: center;
padding: 2rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
backdrop-filter: blur(10px);
}
.spinner {
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid white;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 1rem auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div class="container">
<div class="spinner"></div>
<h2>Authentication successful!</h2>
<p>Redirecting to dashboard...</p>
<p><small>If you are not redirected automatically, <a href="/dashboard" style="color: #ffffff;">click here</a>.</small></p>
</div>
</body>
</html>
`;
} catch (error: any) { } catch (error: any) {
const duration = Date.now() - startTime const duration = Date.now() - startTime

View File

@ -2,15 +2,14 @@ import { keycloakClient } from '~/server/utils/keycloak-client'
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const startTime = Date.now() const startTime = Date.now()
const requestId = Math.random().toString(36).substring(7) console.log('[REFRESH] Processing token refresh request')
console.log(`[REFRESH:${requestId}] Processing token refresh request`)
try { try {
// Get current session // Get current session
const oidcSession = getCookie(event, 'nuxt-oidc-auth') const oidcSession = getCookie(event, 'nuxt-oidc-auth')
if (!oidcSession) { if (!oidcSession) {
console.error(`[REFRESH:${requestId}] No session found`) console.error('[REFRESH] No session found')
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'No session found' statusMessage: 'No session found'
@ -21,7 +20,7 @@ export default defineEventHandler(async (event) => {
try { try {
sessionData = JSON.parse(oidcSession) sessionData = JSON.parse(oidcSession)
} catch (parseError) { } catch (parseError) {
console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError) console.error('[REFRESH] Failed to parse session:', parseError)
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'Invalid session format' statusMessage: 'Invalid session format'
@ -30,7 +29,7 @@ export default defineEventHandler(async (event) => {
// Check if we have a refresh token // Check if we have a refresh token
if (!sessionData.refreshToken) { if (!sessionData.refreshToken) {
console.error(`[REFRESH:${requestId}] No refresh token available`) console.error('[REFRESH] No refresh token available')
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
statusMessage: 'No refresh token available' statusMessage: 'No refresh token available'
@ -40,48 +39,24 @@ export default defineEventHandler(async (event) => {
// Validate environment variables // Validate environment variables
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
if (!clientSecret) { if (!clientSecret) {
console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`) console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Authentication service misconfigured' statusMessage: 'Authentication service misconfigured'
}) })
} }
// Use refresh token to get new access token with enhanced error handling // Use refresh token to get new access token with retry logic
console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`) console.log('[REFRESH] Using Keycloak client for token refresh...')
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken) const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
.catch((error: any) => {
// Check if it's a transient error
if (error.statusMessage === 'KEYCLOAK_TEMPORARILY_UNAVAILABLE') {
console.log(`[REFRESH:${requestId}] Keycloak temporarily unavailable, using grace period`)
// Return current session with extended grace period
return {
success: true,
expiresAt: sessionData.expiresAt,
gracePeriod: true
}
}
throw error // Re-throw for permanent failures
})
const refreshDuration = Date.now() - startTime const refreshDuration = Date.now() - startTime
console.log(`[REFRESH:${requestId}] Token refresh successful in ${refreshDuration}ms:`, { console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, {
hasAccessToken: !!tokenResponse.access_token, hasAccessToken: !!tokenResponse.access_token,
hasRefreshToken: !!tokenResponse.refresh_token, hasRefreshToken: !!tokenResponse.refresh_token,
expiresIn: tokenResponse.expires_in, expiresIn: tokenResponse.expires_in
gracePeriod: tokenResponse.gracePeriod
}) })
// Handle grace period response
if (tokenResponse.gracePeriod) {
console.log(`[REFRESH:${requestId}] Using grace period - session extended`)
return {
success: true,
expiresAt: tokenResponse.expiresAt,
gracePeriod: true
}
}
// Update session with new tokens // Update session with new tokens
const updatedSessionData = { const updatedSessionData = {
...sessionData, ...sessionData,
@ -104,7 +79,7 @@ export default defineEventHandler(async (event) => {
path: '/' path: '/'
}) })
console.log(`[REFRESH:${requestId}] Session updated successfully`) console.log('[REFRESH] Session updated successfully')
return { return {
success: true, success: true,
@ -112,17 +87,14 @@ export default defineEventHandler(async (event) => {
} }
} catch (error: any) { } catch (error: any) {
console.error(`[REFRESH:${requestId}] Token refresh failed:`, error) console.error('[REFRESH] Token refresh failed:', error)
// Only clear session for permanent failures // Clear invalid session
if (error.statusMessage === 'REFRESH_TOKEN_INVALID') {
console.log(`[REFRESH:${requestId}] Clearing session due to invalid refresh token`)
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev'; const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
deleteCookie(event, 'nuxt-oidc-auth', { deleteCookie(event, 'nuxt-oidc-auth', {
domain: cookieDomain, domain: cookieDomain,
path: '/' path: '/'
}) })
}
throw createError({ throw createError({
statusCode: 401, statusCode: 401,

View File

@ -1,33 +1,22 @@
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const requestId = Math.random().toString(36).substring(7) console.log('[SESSION] Checking authentication session...')
const startTime = Date.now()
console.log(`[SESSION:${requestId}] Checking authentication session...`)
// Check OIDC/Keycloak authentication only // Check OIDC/Keycloak authentication only
try { try {
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth') const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
if (!oidcSessionCookie) { if (!oidcSessionCookie) {
console.log(`[SESSION:${requestId}] No OIDC session cookie found`) console.log('[SESSION] No OIDC session cookie found')
return { return { user: null, authenticated: false, groups: [] }
user: null,
authenticated: false,
groups: [],
reason: 'NO_SESSION_COOKIE',
requestId
}
} }
console.log(`[SESSION:${requestId}] OIDC session cookie found, parsing...`) console.log('[SESSION] OIDC session cookie found, parsing...')
let sessionData let sessionData
try { try {
// Parse the session data // Parse the session data
const parseStart = Date.now()
sessionData = JSON.parse(oidcSessionCookie) sessionData = JSON.parse(oidcSessionCookie)
const parseTime = Date.now() - parseStart console.log('[SESSION] Session data parsed successfully:', {
console.log(`[SESSION:${requestId}] Session data parsed successfully in ${parseTime}ms:`, {
hasUser: !!sessionData.user, hasUser: !!sessionData.user,
hasAccessToken: !!sessionData.accessToken, hasAccessToken: !!sessionData.accessToken,
hasIdToken: !!sessionData.idToken, hasIdToken: !!sessionData.idToken,
@ -36,25 +25,19 @@ export default defineEventHandler(async (event) => {
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown' timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
}) })
} catch (parseError) { } catch (parseError) {
console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError) console.error('[SESSION] Failed to parse session cookie:', parseError)
// Clear invalid session // Clear invalid session
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev'; const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
deleteCookie(event, 'nuxt-oidc-auth', { deleteCookie(event, 'nuxt-oidc-auth', {
domain: cookieDomain, domain: cookieDomain,
path: '/' path: '/'
}) })
return { return { user: null, authenticated: false, groups: [] }
user: null,
authenticated: false,
groups: [],
reason: 'INVALID_SESSION_FORMAT',
requestId
}
} }
// Validate session structure // Validate session structure
if (!sessionData.user || !sessionData.accessToken) { if (!sessionData.user || !sessionData.accessToken) {
console.error(`[SESSION:${requestId}] Invalid session structure:`, { console.error('[SESSION] Invalid session structure:', {
hasUser: !!sessionData.user, hasUser: !!sessionData.user,
hasAccessToken: !!sessionData.accessToken hasAccessToken: !!sessionData.accessToken
}) })
@ -63,18 +46,12 @@ export default defineEventHandler(async (event) => {
domain: cookieDomain, domain: cookieDomain,
path: '/' path: '/'
}) })
return { return { user: null, authenticated: false, groups: [] }
user: null,
authenticated: false,
groups: [],
reason: 'INVALID_SESSION_STRUCTURE',
requestId
}
} }
// Check if session is still valid // Check if session is still valid
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) { if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
console.log(`[SESSION:${requestId}] Session expired:`, { console.log('[SESSION] Session expired:', {
expiresAt: sessionData.expiresAt, expiresAt: sessionData.expiresAt,
currentTime: Date.now(), currentTime: Date.now(),
expiredSince: Date.now() - sessionData.expiresAt expiredSince: Date.now() - sessionData.expiresAt
@ -85,13 +62,7 @@ export default defineEventHandler(async (event) => {
domain: cookieDomain, domain: cookieDomain,
path: '/' path: '/'
}) })
return { return { user: null, authenticated: false, groups: [] }
user: null,
authenticated: false,
groups: [],
reason: 'SESSION_EXPIRED',
requestId
}
} }
// Extract groups from ID token // Extract groups from ID token

View File

@ -8,8 +8,6 @@ export default defineEventHandler(async (event) => {
await requireAuth(event); await requireAuth(event);
console.log('[Delete Generated EOI] Request received'); console.log('[Delete Generated EOI] Request received');
console.log('[Delete Generated EOI] Request headers:', getHeaders(event));
console.log('[Delete Generated EOI] Request method:', getMethod(event));
try { try {
const body = await readBody(event); const body = await readBody(event);
@ -17,13 +15,12 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
console.log('[Delete Generated EOI] Interest ID:', interestId); console.log('[Delete Generated EOI] Interest ID:', interestId);
console.log('[Delete Generated EOI] Query params:', query);
if (!interestId) { if (!interestId) {
console.error('[Delete Generated EOI] No interest ID provided'); console.error('[Delete Generated EOI] No interest ID provided');
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Interest ID is required. Please provide a valid interest ID.', statusMessage: 'Interest ID is required',
}); });
} }

View File

@ -1,6 +1,5 @@
import { requireSalesOrAdmin } from '~/server/utils/auth'; import { requireSalesOrAdmin } from '~/server/utils/auth';
import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb'; import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
import { findDuplicates, createExpenseConfig } from '~/server/utils/duplicate-detection';
import type { Expense } from '~/utils/types'; import type { Expense } from '~/utils/types';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -36,31 +35,21 @@ export default defineEventHandler(async (event) => {
const expenses = response.list || []; const expenses = response.list || [];
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates'); console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
// Find duplicate groups using the new centralized utility // Find duplicate groups
const duplicateConfig = createExpenseConfig(); const duplicateGroups = findDuplicateExpenses(expenses);
const duplicateGroups = findDuplicates(expenses, duplicateConfig);
// Convert to the expected format
const formattedGroups = duplicateGroups.map(group => ({
id: group.id,
expenses: group.items,
matchReason: group.matchReason,
confidence: group.confidence,
masterCandidate: group.masterCandidate
}));
// Also find payer name variations // Also find payer name variations
const payerVariations = findPayerNameVariations(expenses); const payerVariations = findPayerNameVariations(expenses);
console.log('[EXPENSES] Found', formattedGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations'); console.log('[EXPENSES] Found', duplicateGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations');
return { return {
success: true, success: true,
data: { data: {
duplicateGroups: formattedGroups, duplicateGroups,
payerVariations, payerVariations,
totalExpenses: expenses.length, totalExpenses: expenses.length,
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.expenses.length, 0), duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.expenses.length, 0),
dateRange: { dateRange: {
start: startDate.toISOString().split('T')[0], start: startDate.toISOString().split('T')[0],
end: endDate.toISOString().split('T')[0] end: endDate.toISOString().split('T')[0]
@ -85,6 +74,71 @@ export default defineEventHandler(async (event) => {
} }
}); });
/**
* Find duplicate expenses based on multiple criteria
*/
function findDuplicateExpenses(expenses: any[]) {
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
const duplicateGroups: Array<{
id: string;
expenses: any[];
matchReason: string;
confidence: number;
masterCandidate: any;
}> = [];
const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < expenses.length; i++) {
const expense1 = expenses[i];
if (processedIds.has(expense1.Id)) continue;
const matches = [expense1];
let matchReasons = new Set<string>();
for (let j = i + 1; j < expenses.length; j++) {
const expense2 = expenses[j];
if (processedIds.has(expense2.Id)) continue;
const similarity = calculateExpenseSimilarity(expense1, expense2);
comparisons++;
console.log(`[EXPENSES] Comparing ${expense1.Id} vs ${expense2.Id}: score=${similarity.score.toFixed(3)}, threshold=0.7`);
if (similarity.score >= 0.7) { // Lower threshold for expenses
console.log(`[EXPENSES] MATCH FOUND! ${expense1.Id} vs ${expense2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[EXPENSES] Match reasons:', similarity.reasons);
matches.push(expense2);
processedIds.add(expense2.Id);
similarity.reasons.forEach(r => matchReasons.add(r));
}
}
if (matches.length > 1) {
// Mark all as processed
matches.forEach(match => processedIds.add(match.Id));
// Determine the best master candidate
const masterCandidate = selectMasterExpense(matches);
duplicateGroups.push({
id: `group_${duplicateGroups.length + 1}`,
expenses: matches,
matchReason: Array.from(matchReasons).join(', '),
confidence: Math.max(...matches.slice(1).map(match =>
calculateExpenseSimilarity(masterCandidate, match).score
)),
masterCandidate
});
}
}
return duplicateGroups;
}
/** /**
* Find payer name variations (like "Abbie" vs "abbie") * Find payer name variations (like "Abbie" vs "abbie")
@ -127,3 +181,154 @@ function findPayerNameVariations(expenses: any[]) {
return variations.sort((a, b) => b.expenseCount - a.expenseCount); return variations.sort((a, b) => b.expenseCount - a.expenseCount);
} }
/**
* Calculate similarity between two expenses
*/
function calculateExpenseSimilarity(expense1: any, expense2: any) {
const scores: Array<{ type: string; score: number; weight: number }> = [];
const reasons: string[] = [];
// Exact match on establishment, price, and date (highest weight for true duplicates)
if (expense1['Establishment Name'] === expense2['Establishment Name'] &&
expense1.Price === expense2.Price &&
expense1.Time === expense2.Time) {
scores.push({ type: 'exact', score: 1.0, weight: 0.5 });
reasons.push('Exact match');
}
// Same payer, establishment, and price on same day (likely duplicate)
const date1 = expense1.Time?.split('T')[0];
const date2 = expense2.Time?.split('T')[0];
if (normalizePersonName(expense1.Payer) === normalizePersonName(expense2.Payer) &&
expense1['Establishment Name'] === expense2['Establishment Name'] &&
expense1.Price === expense2.Price &&
date1 === date2) {
scores.push({ type: 'same-day', score: 0.95, weight: 0.4 });
reasons.push('Same person, place, amount on same day');
}
// Similar establishment names with same price and payer
if (expense1['Establishment Name'] && expense2['Establishment Name']) {
const nameSimilarity = calculateStringSimilarity(
expense1['Establishment Name'],
expense2['Establishment Name']
);
if (nameSimilarity > 0.8 &&
expense1.Price === expense2.Price &&
normalizePersonName(expense1.Payer) === normalizePersonName(expense2.Payer)) {
scores.push({ type: 'similar', score: nameSimilarity, weight: 0.3 });
reasons.push('Similar establishment name');
}
}
// Time proximity check (within 5 minutes)
if (expense1.Time && expense2.Time) {
const time1 = new Date(expense1.Time).getTime();
const time2 = new Date(expense2.Time).getTime();
const timeDiff = Math.abs(time1 - time2);
if (timeDiff < 5 * 60 * 1000 && // 5 minutes
expense1['Establishment Name'] === expense2['Establishment Name']) {
scores.push({ type: 'time-proximity', score: 0.9, weight: 0.2 });
reasons.push('Within 5 minutes at same establishment');
}
}
// Calculate weighted average
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
const weightedScore = totalWeight > 0
? scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / totalWeight
: 0;
return {
score: weightedScore,
reasons,
details: scores
};
}
/**
* Calculate string similarity using Levenshtein distance
*/
function calculateStringSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase().trim();
const s2 = str2.toLowerCase().trim();
if (s1 === s2) return 1.0;
const distance = levenshteinDistance(s1, s2);
const maxLength = Math.max(s1.length, s2.length);
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i += 1) {
matrix[0][i] = i;
}
for (let j = 0; j <= str2.length; j += 1) {
matrix[j][0] = j;
}
for (let j = 1; j <= str2.length; j += 1) {
for (let i = 1; i <= str1.length; i += 1) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // deletion
matrix[j - 1][i] + 1, // insertion
matrix[j - 1][i - 1] + indicator // substitution
);
}
}
return matrix[str2.length][str1.length];
}
/**
* Select the best master expense from a group
*/
function selectMasterExpense(expenses: any[]) {
return expenses.reduce((best, current) => {
const bestScore = calculateExpenseCompletenessScore(best);
const currentScore = calculateExpenseCompletenessScore(current);
return currentScore > bestScore ? current : best;
});
}
/**
* Calculate completeness score for an expense
*/
function calculateExpenseCompletenessScore(expense: any): number {
const fields = ['Establishment Name', 'Price', 'Payer', 'Category', 'Contents', 'Time'];
const filledFields = fields.filter(field =>
expense[field] && expense[field].toString().trim().length > 0
);
let score = filledFields.length / fields.length;
// Bonus for having contents description
if (expense.Contents && expense.Contents.length > 10) {
score += 0.2;
}
// Bonus for recent creation (more likely to be accurate)
if (expense.CreatedAt) {
const created = new Date(expense.CreatedAt);
const now = new Date();
const hoursOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60);
if (hoursOld < 24) score += 0.1;
}
return Math.min(score, 1.0);
}

View File

@ -748,22 +748,56 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...'); console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
try { try {
// Use the signed URL directly without modification to preserve AWS signature // Ensure URL is properly encoded
console.log('[expenses/generate-pdf] Fetching from S3 URL (preserving signature):', rawPath); 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;
}
// Fetch image directly from S3 URL with minimal headers to avoid signature issues // Fetch image directly from S3 URL with proper headers
const response = await fetch(rawPath, { const response = await fetch(encodedUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'image/*' 'Accept': 'image/*',
'User-Agent': 'PortNimara-Client-Portal/1.0',
'Cache-Control': 'no-cache'
}, },
// Add timeout to prevent hanging // Add timeout to prevent hanging
signal: AbortSignal.timeout(30000) // 30 second timeout signal: AbortSignal.timeout(45000) // 45 second timeout
}); });
if (!response.ok) { if (!response.ok) {
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`); console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries())); console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries()));
// Try with the original URL if encoding failed
if (encodedUrl !== rawPath) {
console.log('[expenses/generate-pdf] Retrying with original URL...');
const originalResponse = await fetch(rawPath, {
method: 'GET',
headers: {
'Accept': 'image/*',
'User-Agent': 'PortNimara-Client-Portal/1.0'
},
signal: AbortSignal.timeout(30000)
});
if (originalResponse.ok) {
const arrayBuffer = await originalResponse.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer);
console.log('[expenses/generate-pdf] Successfully fetched with original URL, Size:', imageBuffer.length);
return imageBuffer;
}
}
return null; return null;
} }
@ -776,13 +810,27 @@ async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
} catch (fetchError: any) { } catch (fetchError: any) {
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message); console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
console.error('[expenses/generate-pdf] Error details:', {
name: fetchError.name, // If it's a timeout or network error, try one more time with simpler approach
code: fetchError.code, if (fetchError.name === 'TimeoutError' || fetchError.name === 'AbortError' || fetchError.code === 'ECONNRESET') {
message: fetchError.message 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
}); });
// Don't try multiple attempts for signed URLs as they may expire 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);
}
}
return null; return null;
} }
} }

View File

@ -3,43 +3,6 @@ import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency'; import { processExpenseWithCurrency } from '@/server/utils/currency';
import type { ExpenseFilters } from '@/utils/types'; import type { ExpenseFilters } from '@/utils/types';
// Retry operation wrapper for database calls
async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
console.log(`[get-expenses] Attempt ${attempt}/${maxRetries} failed:`, error.message);
// Don't retry on authentication/authorization errors
if (error.statusCode === 401 || error.statusCode === 403) {
throw error;
}
// Don't retry on client errors (4xx except 404)
if (error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 404) {
throw error;
}
// If this is the last attempt, throw the error
if (attempt === maxRetries) {
throw error;
}
// For retryable errors (5xx, network errors, timeouts), wait before retry
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
console.log(`[get-expenses] Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Retry operation failed unexpectedly');
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
console.log('[get-expenses] API called with query:', getQuery(event)); console.log('[get-expenses] API called with query:', getQuery(event));
@ -47,7 +10,6 @@ export default defineEventHandler(async (event) => {
// Set proper headers // Set proper headers
setHeader(event, 'Cache-Control', 'no-cache'); setHeader(event, 'Cache-Control', 'no-cache');
setHeader(event, 'Content-Type', 'application/json'); setHeader(event, 'Content-Type', 'application/json');
// Check authentication first // Check authentication first
try { try {
await requireSalesOrAdmin(event); await requireSalesOrAdmin(event);
@ -74,7 +36,7 @@ export default defineEventHandler(async (event) => {
console.log('[get-expenses] No date filters provided, defaulting to current month'); console.log('[get-expenses] No date filters provided, defaulting to current month');
try { try {
const result = await retryOperation(() => getCurrentMonthExpenses()); const result = await getCurrentMonthExpenses();
// Process expenses with currency conversion // Process expenses with currency conversion
const processedExpenses = await Promise.all( const processedExpenses = await Promise.all(
@ -95,13 +57,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
if (dbError.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'No expense records found for the current month.'
});
}
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Unable to fetch expense data. Please try again later.' statusMessage: 'Unable to fetch expense data. Please try again later.'
@ -131,7 +86,7 @@ export default defineEventHandler(async (event) => {
console.log('[get-expenses] Fetching expenses with filters:', filters); console.log('[get-expenses] Fetching expenses with filters:', filters);
try { try {
const result = await retryOperation(() => getExpenses(filters)); const result = await getExpenses(filters);
// Process expenses with currency conversion // Process expenses with currency conversion
const processedExpenses = await Promise.all( const processedExpenses = await Promise.all(
@ -171,13 +126,6 @@ export default defineEventHandler(async (event) => {
}); });
} }
if (dbError.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'No expense records found matching the specified criteria.'
});
}
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Unable to fetch expense data. Please try again later.' statusMessage: 'Unable to fetch expense data. Please try again later.'

View File

@ -1,7 +1,6 @@
import { requireSalesOrAdmin } from '~/server/utils/auth'; import { requireSalesOrAdmin } from '~/server/utils/auth';
import { getNocoDbConfiguration } from '~/server/utils/nocodb'; import { getNocoDbConfiguration } from '~/server/utils/nocodb';
import { logAuditEvent } from '~/server/utils/audit-logger'; import { logAuditEvent } from '~/server/utils/audit-logger';
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
console.log('[INTERESTS] Find duplicates request'); console.log('[INTERESTS] Find duplicates request');
@ -20,12 +19,11 @@ export default defineEventHandler(async (event) => {
let url = `${config.url}/api/v2/tables/${interestTableId}/records`; let url = `${config.url}/api/v2/tables/${interestTableId}/records`;
// Add date filtering if specified (include records without Created At) // Add date filtering if specified
if (dateRange && dateRange > 0) { if (dateRange && dateRange > 0) {
const cutoffDate = new Date(); const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - dateRange); cutoffDate.setDate(cutoffDate.getDate() - dateRange);
// Include records without Created At OR within date range const dateFilter = `(Created At,gte,${cutoffDate.toISOString()})`;
const dateFilter = `((Created At,gte,${cutoffDate.toISOString()}),or,(Created At,is,null))`;
url += `?where=${encodeURIComponent(dateFilter)}`; url += `?where=${encodeURIComponent(dateFilter)}`;
} }
@ -41,26 +39,16 @@ export default defineEventHandler(async (event) => {
const interests = response.list || []; const interests = response.list || [];
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates'); console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
// Find duplicate groups using the new centralized utility // Find potential duplicates
const duplicateConfig = createInterestConfig(); const duplicateGroups = findDuplicateInterests(interests, threshold);
const duplicateGroups = findDuplicates(interests, duplicateConfig);
// Convert to the expected format console.log('[INTERESTS] Found', duplicateGroups.length, 'duplicate groups');
const formattedGroups = duplicateGroups.map(group => ({
id: group.id,
interests: group.items,
matchReason: group.matchReason,
confidence: group.confidence,
masterCandidate: group.masterCandidate
}));
console.log('[INTERESTS] Found', formattedGroups.length, 'duplicate groups');
// Log the audit event // Log the audit event
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', { await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
changes: { changes: {
totalInterests: interests.length, totalInterests: interests.length,
duplicateGroups: formattedGroups.length, duplicateGroups: duplicateGroups.length,
threshold, threshold,
dateRange dateRange
} }
@ -69,9 +57,9 @@ export default defineEventHandler(async (event) => {
return { return {
success: true, success: true,
data: { data: {
duplicateGroups: formattedGroups, duplicateGroups,
totalInterests: interests.length, totalInterests: interests.length,
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0), duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
threshold, threshold,
dateRange dateRange
} }
@ -93,3 +81,288 @@ 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';
}

View File

@ -1,417 +0,0 @@
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
};
}

View File

@ -184,8 +184,7 @@ class KeycloakClient {
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token' const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
try { return this.fetch(tokenUrl, {
const response = await this.fetch(tokenUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
@ -198,30 +197,8 @@ class KeycloakClient {
}).toString() }).toString()
}, { }, {
timeout: 15000, timeout: 15000,
retries: 2 // Increased from 1 retries: 1 // Only 1 retry for refresh operations
}) })
// Log successful refresh
console.log('[KEYCLOAK_CLIENT] Token refresh successful')
return response
} catch (error: any) {
// Distinguish between error types
if (error.status === 400 || error.status === 401) {
// Refresh token expired or invalid
console.error('[KEYCLOAK_CLIENT] Refresh token invalid:', error.status)
throw createError({
statusCode: 401,
statusMessage: 'REFRESH_TOKEN_INVALID'
})
}
// Network or server error - might be transient
console.error('[KEYCLOAK_CLIENT] Refresh failed (transient?):', error)
throw createError({
statusCode: 503,
statusMessage: 'KEYCLOAK_TEMPORARILY_UNAVAILABLE'
})
}
} }
getCircuitBreakerStatus() { getCircuitBreakerStatus() {

View File

@ -1,272 +0,0 @@
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 }

View File

@ -1,268 +0,0 @@
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')
})
})
})