Compare commits

...

26 Commits

Author SHA1 Message Date
bed42c7329 Fix visual bugs in expenses page and interest details modal
- Fixed calendar button positioning in date input fields by adding proper padding
- Reduced Current Month button size from large to default for better fit
- Improved export button spacing using ga-4 instead of gap-6
- Removed Save & Close button from InterestDetailsModal, keeping only Save Changes button
- Updated mobile layout to use full width for Save Changes button
2025-07-12 14:46:08 -04:00
0762306bf3 Fix duplicate detection system - improve blocking strategy for interests
- Updated interest blocking strategy to group by name prefix instead of email domain
- This fixes the issue where interests with different email domains were never compared
- Updated admin duplicate finder to use the new centralized utility
- Both Matthew Ciaccio entries will now be detected as duplicates
2025-07-12 14:04:01 -04:00
080cb60d71 feat: Implement centralized duplicate detection utility for expenses and interests 2025-07-12 13:42:53 -04:00
b8a6a52417 fix: Update authentication callback to return HTML with client-side redirect for SPA compatibility 2025-07-12 13:25:45 -04:00
9f7aa99320 fix: Refine sidebar design and navigation
- Make 'Port Nimara CRM' single line with larger logo (40px)
- Center logo perfectly in rail/collapsed mode
- Remove Dashboard button from navigation
- Make Interest List the first navigation item
- Remove duplicate navigation entries
- Improve visual alignment and spacing
2025-07-11 17:30:44 -04:00
1a24faa9db fix: Improve sidebar UX and mobile/PWA compatibility
- Remove Interest Emails from navigation (hidden as requested)
- Change 'Client Portal' to 'CRM' throughout
- Make logo clickable to expand/collapse sidebar (removed separate button)
- Add proper mobile responsiveness with fixed positioning
- Add PWA safe area insets for standalone mode
- Improve visual alignment and smooth transitions
- Fix rail mode width and spacing issues
2025-07-11 17:20:34 -04:00
9b045c7b97 fix: Switch to pure Vuetify components for unified sidebar
- Remove Nuxt UI dependency that was causing conflicts
- Use v-navigation-drawer with rail prop for collapsible functionality
- Implement proper Vuetify list components for navigation
- Add responsive behavior with drawer toggle on mobile
- Fix layout structure to work with existing Vuetify setup
2025-07-11 17:04:13 -04:00
7244349fe7 fix: Resolve 500 error in unified sidebar by using simpler implementation
- Replace problematic UDashboardSidebar components with custom implementation
- Use standard HTML/CSS for sidebar instead of Nuxt UI dashboard components
- Fix 'Cannot destructure property collapsed of undefined' error
- Maintain all features: responsive, role-based nav, clean design
- Ensure compatibility with existing Vuetify components
2025-07-11 16:44:29 -04:00
61235b163d feat: Implement unified sidebar with Nuxt UI across all dashboard pages
- Install @nuxt/ui and integrate with existing Vuetify
- Create new layouts/dashboard-unified.vue with modern sidebar design
- Features: clean white design, collapsible sidebar, role-based navigation
- Remove old layouts/dashboard.vue to eliminate dual-sidebar confusion
- Update all dashboard pages to use dashboard-unified layout
- Add demo page showcasing new sidebar features
- Fix auth error handler to ignore external service 401 errors
- Ensure consistent navigation experience across entire platform
2025-07-11 16:33:11 -04:00
d71e2d348c feat: Update auth error handler to differentiate between app and external service 401 errors, preventing unnecessary session logouts 2025-07-11 16:10:26 -04:00
eb1d853327 feat: Address 404 errors and session management issues, improve authorization middleware to use cached auth state, and adjust auth refresh plugin for better session validation 2025-07-11 15:05:59 -04:00
7ee2cb3368 feat: Implement dashboard layout with navigation and role-based access, enhance authentication middleware to clear cache only on actual auth errors, and update expenses page metadata for authorization checks 2025-07-11 14:57:15 -04:00
c6f81a6686 Refactor authentication to use centralized session manager
Extract session management logic from middleware into reusable SessionManager utility to improve reliability, reduce code duplication, and prevent thundering herd issues with jittered cache expiry.
2025-07-11 14:43:50 -04:00
bf2361050f feat: Enhance authentication middleware with reduced cache expiry, improved session validation, and global error handling for auth-related issues 2025-07-11 11:58:38 -04:00
242e33f7b9 feat: Add "Save & Close" button in InterestDetailsModal, enhance saveInterest function to conditionally close modal, and improve logging in delete-generated-document and get-expenses APIs 2025-07-10 17:36:10 -04:00
6ebe96bbf4 feat: Improve UI styling in ExpenseDetailsModal and ExpenseList, enhance authentication middleware caching, and optimize PDF generation for receipt fetching 2025-07-10 17:05:08 -04:00
3ba8542e4f feat: Add currency selection and conversion support in PDF generation, enhance expense processing with target currency handling 2025-07-10 14:02:14 -04:00
2928d9a7ed feat: Enhance authentication middleware and token refresh logic with improved caching, retry mechanisms, and error handling 2025-07-10 13:31:58 -04:00
6e99f4f783 feat: Enhance receipt processing in PDF generation with overall receipt numbering and improved path extraction for MinIO 2025-07-10 12:57:43 -04:00
a00b3918be feat: Enhance error handling and logging in expense and interest duplicate detection, add retry logic for document deletion, and improve PDF generation with detailed receipt processing 2025-07-10 09:59:17 -04:00
06500a614d updates 2025-07-09 22:47:52 -04:00
9d49245efa refactor: replace Puppeteer with PDFKit for PDF generation
- Updated package.json to remove Puppeteer and add PDFKit and its types.
- Refactored generate-pdf.ts to utilize PDFKit for generating PDFs instead of Puppeteer.
- Implemented functions to add headers, summaries, expense tables, and receipt images using PDFKit.
- Removed HTML content generation and related functions, streamlining the PDF generation process.
- Added error handling for receipt image fetching and improved logging.
2025-07-09 22:38:46 -04:00
893927d4b1 Refactor expense form and add PDF generation functionality
- Update expense form fields (merchant->establishmentName, amount->price)
- Add PDF generation with Puppeteer integration
- Create PDFOptionsModal component for export options
- Update expense form validation and UI layout
- Add server API endpoint for PDF generation
2025-07-09 22:23:50 -04:00
b6d71faf5f feat: Improve role verification logic in InterestDuplicateNotificationBanner for duplicate checks 2025-07-09 22:08:08 -04:00
3f90db0392 feat: Update distribution method in EOI document generation to 'NONE' 2025-07-09 21:59:06 -04:00
a83895bef3 feat: Update Documenso configuration in .env.example and refactor generate-eoi-document to use environment variables for recipient IDs and template ID 2025-07-09 21:51:42 -04:00
44 changed files with 7355 additions and 1613 deletions

View File

@@ -17,6 +17,10 @@ NUXT_EMAIL_LOGO_URL=https://portnimara.com/Port_Nimara_Logo_2_Colour_New_Transpa
# Documenso Configuration
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
NUXT_DOCUMENSO_TEMPLATE_ID=1
NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID=1
NUXT_DOCUMENSO_DAVID_RECIPIENT_ID=2
NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID=3
# Webhook Configuration for Embedded Signing
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=

1
.gitignore vendored
View File

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

View File

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

View File

@@ -198,7 +198,7 @@
{{ getSignatureStatusText('cc') }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Approval</v-list-item-subtitle>
<template v-slot:append>
<div class="d-flex gap-1">
<v-btn

View File

@@ -7,7 +7,7 @@
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-receipt-text</v-icon>
<v-icon class="mr-2">mdi-plus</v-icon>
<span>Add New Expense</span>
<v-spacer />
<v-btn
@@ -19,44 +19,35 @@
</v-card-title>
<v-card-text>
<v-form ref="form" @submit.prevent="saveExpense">
<v-form ref="form" @submit.prevent="handleSubmit">
<v-row>
<!-- Merchant/Description -->
<!-- Establishment Name -->
<v-col cols="12">
<v-text-field
v-model="expense.merchant"
label="Merchant/Description"
v-model="expense.establishmentName"
label="Establishment Name"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., Shell, American Airlines, etc."
/>
</v-col>
<!-- Amount and Currency -->
<v-col cols="8">
<!-- Price -->
<v-col cols="12" sm="6">
<v-text-field
v-model="expense.amount"
label="Amount"
type="number"
step="0.01"
v-model="expense.price"
label="Price"
variant="outlined"
:rules="[rules.required, rules.positive]"
required
/>
</v-col>
<v-col cols="4">
<v-select
v-model="expense.currency"
:items="currencies"
label="Currency"
variant="outlined"
:rules="[rules.required]"
:rules="[rules.required, rules.price]"
required
placeholder="e.g., 59.95"
prepend-inner-icon="mdi-currency-eur"
/>
</v-col>
<!-- Category -->
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-select
v-model="expense.category"
:items="categories"
@@ -68,60 +59,49 @@
</v-col>
<!-- Payer -->
<v-col cols="12" md="6">
<v-col cols="12" sm="6">
<v-text-field
v-model="expense.payer"
label="Payer"
variant="outlined"
:rules="[rules.required]"
required
placeholder="e.g., John, Mary, etc."
/>
</v-col>
<!-- Payment Method -->
<v-col cols="12" sm="6">
<v-select
v-model="expense.paymentMethod"
:items="paymentMethods"
label="Payment Method"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Date -->
<v-col cols="12" md="6">
<v-col cols="12">
<v-text-field
v-model="expense.date"
label="Date"
type="date"
label="Date"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Time -->
<v-col cols="12" md="6">
<v-text-field
v-model="expense.time"
label="Time"
type="time"
variant="outlined"
:rules="[rules.required]"
required
/>
</v-col>
<!-- Notes -->
<!-- Contents/Description -->
<v-col cols="12">
<v-textarea
v-model="expense.notes"
label="Notes (Optional)"
v-model="expense.contents"
label="Description (optional)"
variant="outlined"
rows="3"
auto-grow
/>
</v-col>
<!-- Receipt Upload -->
<v-col cols="12">
<v-file-input
v-model="expense.receipt"
label="Receipt Image (Optional)"
accept="image/*"
variant="outlined"
prepend-icon="mdi-camera"
show-size
placeholder="Additional details about the expense..."
/>
</v-col>
</v-row>
@@ -133,17 +113,19 @@
<v-btn
@click="closeModal"
variant="text"
:disabled="saving"
:disabled="creating"
>
Cancel
</v-btn>
<v-btn
@click="saveExpense"
@click="handleSubmit"
:disabled="creating"
color="primary"
:loading="saving"
:disabled="!isValid"
:loading="creating"
>
Add Expense
<v-icon v-if="!creating" class="mr-1">mdi-plus</v-icon>
Create Expense
</v-btn>
</v-card-actions>
</v-card>
@@ -151,82 +133,81 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue';
import type { Expense } from '@/utils/types';
import { ref, computed, watch } from 'vue';
// Props
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:modelValue', value: boolean): void;
(e: 'created', expense: Expense): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'created': [expense: any];
}>();
// Computed dialog model
const dialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
// Reactive state
const form = ref();
const saving = ref(false);
const creating = ref(false);
// Form data
const expense = ref({
merchant: '',
amount: '',
currency: 'EUR',
establishmentName: '',
price: '',
category: '',
payer: '',
paymentMethod: '',
date: '',
time: '',
notes: '',
receipt: null as File[] | null
contents: ''
});
// Form options
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
const categories = [
'Food/Drinks',
'Shop',
'Online',
'Other'
];
const paymentMethods = [
'Card',
'Cash',
'N/A'
];
// Validation rules
const rules = {
required: (value: any) => !!value || 'This field is required',
positive: (value: any) => {
required: (value: string) => !!value || 'This field is required',
price: (value: string) => {
if (!value) return 'Price is required';
const num = parseFloat(value);
return (!isNaN(num) && num > 0) || 'Amount must be positive';
if (isNaN(num) || num <= 0) return 'Please enter a valid price';
return true;
}
};
// Computed properties
const isValid = computed(() => {
return !!(
expense.value.merchant &&
expense.value.amount &&
expense.value.currency &&
expense.value.category &&
expense.value.payer &&
expense.value.date &&
expense.value.time &&
parseFloat(expense.value.amount) > 0
);
});
// Methods
const closeModal = () => {
dialog.value = false;
resetForm();
};
const resetForm = () => {
const now = new Date();
expense.value = {
merchant: '',
amount: '',
currency: 'EUR',
establishmentName: '',
price: '',
category: '',
payer: '',
date: now.toISOString().slice(0, 10),
time: now.toTimeString().slice(0, 5),
notes: '',
receipt: null
paymentMethod: '',
date: '',
contents: ''
};
if (form.value) {
@@ -234,89 +215,56 @@ const resetForm = () => {
}
};
const closeModal = () => {
if (!saving.value) {
dialog.value = false;
}
};
const saveExpense = async () => {
const handleSubmit = async () => {
if (!form.value) return;
const { valid } = await form.value.validate();
if (!valid) return;
saving.value = true;
creating.value = true;
try {
// Combine date and time for the API
const dateTime = `${expense.value.date}T${expense.value.time}:00`;
// Prepare the expense data
const expenseData = {
"Establishment Name": expense.value.merchant,
Price: `${expense.value.currency}${expense.value.amount}`,
Category: expense.value.category,
Payer: expense.value.payer,
Time: dateTime,
Contents: expense.value.notes || null,
"Payment Method": "Card", // Default to Card for now
Paid: false,
currency: expense.value.currency
};
console.log('[ExpenseCreateModal] Creating expense:', expenseData);
// Call API to create expense
const response = await $fetch<Expense>('/api/create-expense', {
// Create expense via API
const response = await $fetch<{
success: boolean;
data?: any;
message?: string;
}>('/api/create-expense', {
method: 'POST',
body: expenseData
body: {
'Establishment Name': expense.value.establishmentName,
'Price': expense.value.price,
'Category': expense.value.category,
'Payer': expense.value.payer,
'Payment Method': expense.value.paymentMethod,
'Time': expense.value.date,
'Contents': expense.value.contents
}
});
console.log('[ExpenseCreateModal] Expense created successfully:', response);
// Emit the created event
emit('created', response);
// Close the modal
dialog.value = false;
if (response.success) {
emit('created', response.data);
closeModal();
}
} catch (error: any) {
console.error('[ExpenseCreateModal] Error creating expense:', error);
// Show error message (you might want to use a toast notification here)
alert('Failed to create expense. Please try again.');
// Handle error display here if needed
} finally {
saving.value = false;
creating.value = false;
}
};
// Watch for modal open/close
watch(dialog, (newValue) => {
if (newValue) {
// Reset form when modal opens
nextTick(() => {
resetForm();
});
// Watch for modal open to set default date
watch(dialog, (isOpen) => {
if (isOpen && !expense.value.date) {
expense.value.date = new Date().toISOString().slice(0, 10);
}
});
// Initialize form with current date/time
onMounted(() => {
resetForm();
});
</script>
<style scoped>
.v-dialog > .v-card {
overflow: visible;
}
.v-form {
width: 100%;
}
.v-card-actions {
border-top: 1px solid rgba(0, 0, 0, 0.12);
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
</style>

View File

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

View File

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

View File

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

View File

@@ -50,15 +50,16 @@ const checkForDuplicates = async () => {
try {
loading.value = true;
// Check roles with better error handling
// Check roles with better error handling - use hasAnyRole for multiple roles
const { hasAnyRole, isAdmin, isSalesOrAdmin } = useAuthorization();
let canViewDuplicates = false;
try {
canViewDuplicates = await hasRole(['sales', 'admin']);
canViewDuplicates = isSalesOrAdmin(); // Use the convenience method
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
} catch (roleError) {
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
// Try to get user info directly as fallback
const { isAdmin } = useAuthorization();
canViewDuplicates = isAdmin();
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
}

View File

@@ -103,6 +103,19 @@
</template>
</v-checkbox>
<v-checkbox
v-model="options.includeReceiptContents"
color="primary"
hide-details
>
<template #label>
<div>
<div class="font-weight-medium">Include Receipt Contents</div>
<div class="text-caption text-grey-darken-1">Show receipt description/contents in detail table</div>
</div>
</template>
</v-checkbox>
<v-checkbox
v-model="options.includeProcessingFee"
color="primary"
@@ -119,8 +132,21 @@
</v-card>
</v-col>
<!-- Currency Selection -->
<v-col cols="12" md="6">
<v-select
v-model="options.targetCurrency"
:items="currencyOptions"
label="Export Currency"
variant="outlined"
item-title="text"
item-value="value"
prepend-inner-icon="mdi-currency-usd"
/>
</v-col>
<!-- Page Format -->
<v-col cols="12">
<v-col cols="12" md="6">
<v-select
v-model="options.pageFormat"
:items="pageFormatOptions"
@@ -204,10 +230,12 @@ interface PDFOptions {
subheader: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeReceiptContents: boolean;
includeSummary: boolean;
includeDetails: boolean;
includeProcessingFee: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
targetCurrency: 'USD' | 'EUR';
}
// Computed dialog model
@@ -225,10 +253,12 @@ const options = ref<PDFOptions>({
subheader: '',
groupBy: 'payer',
includeReceipts: true,
includeReceiptContents: true,
includeSummary: true,
includeDetails: true,
includeProcessingFee: true,
pageFormat: 'A4'
pageFormat: 'A4',
targetCurrency: 'EUR'
});
// Form options
@@ -245,6 +275,11 @@ const pageFormatOptions = [
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
];
const currencyOptions = [
{ text: 'Euro (EUR)', value: 'EUR' },
{ text: 'US Dollar (USD)', value: 'USD' }
];
// Validation rules
const rules = {
required: (value: string) => !!value || 'This field is required'

View File

@@ -0,0 +1,182 @@
# 404 and Session Expiration Fixes
## Issues Addressed
1. **404 Error on Expenses Page** - The expenses page was returning a 404 error
2. **Session Expiration After 404** - Users were getting logged out after encountering the 404 error
3. **Immediate Session Expiration** - Users were getting logged out immediately after logging in
4. **External Service 401 Errors** - 401 errors from external services (CMS, database) were logging users out
## Root Cause Analysis
### 404 Error Cause
- The expenses page was missing the authorization middleware configuration
- The dashboard layout referenced in the page metadata didn't exist
- Nuxt wasn't properly configured to use layouts
### Session Expiration Cause
- The authentication middleware was incorrectly clearing the session cache on ALL errors (including 404s)
- This caused a valid session to be invalidated when encountering any page error
### Immediate Logout Cause
- The authorization middleware was making its own API call, bypassing the session cache
- The auth refresh plugin's 2-minute periodic validation was conflicting with the 3-minute session cache
- Multiple concurrent session checks were causing race conditions
### External Service 401 Cause
- The auth error handler was treating ANY 401 error as a session expiration
- When the CMS (`cms.portnimara.dev`) returned 401, it triggered a logout
- The handler didn't distinguish between auth API errors and external service errors
## Fixes Implemented
### 1. Fixed Expenses Page Metadata
**File**: `pages/dashboard/expenses.vue`
Added proper middleware configuration:
```javascript
definePageMeta({
middleware: ['authentication', 'authorization'],
layout: 'dashboard',
roles: ['sales', 'admin']
});
```
This ensures:
- Authentication is checked first
- Authorization checks for sales/admin roles
- Proper layout is applied
### 2. Fixed Authentication Middleware
**File**: `middleware/authentication.ts`
Updated error handling to only clear cache on actual auth errors:
```javascript
onResponseError({ response }) {
// Clear cache only on actual auth errors, not 404s or other errors
if (response.status === 401) {
console.log('[MIDDLEWARE] Unauthorized error detected, clearing cache')
sessionManager.clearCache();
delete nuxtApp.payload.data?.authState;
} else if (response.status === 403) {
console.log('[MIDDLEWARE] Forbidden error detected, partial cache clear')
// Don't clear cache on 403 as user is authenticated but lacks permissions
}
// Ignore 404s and other errors - they're not authentication issues
}
```
### 3. Enabled Layout Support
**File**: `app.vue`
Updated to support layouts:
```vue
<template>
<NuxtPwaManifest />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<GlobalToast />
</template>
```
### 4. Created Dashboard Layout
**File**: `layouts/dashboard.vue`
Created a full dashboard layout with:
- Navigation drawer with role-based menu items
- App bar showing user info and role badges
- Proper logout functionality
- Responsive design with rail mode
- Safe auth state access to prevent initialization errors
### 5. Fixed Authorization Middleware
**File**: `middleware/authorization.ts`
Updated to use cached auth state instead of making API calls:
```javascript
// Get auth state from authentication middleware (already cached)
const nuxtApp = useNuxtApp();
const authState = nuxtApp.payload?.data?.authState;
```
This prevents:
- Duplicate API calls
- Race conditions between middlewares
- Session cache conflicts
### 6. Adjusted Auth Refresh Plugin
**File**: `plugins/01.auth-refresh.client.ts`
- Changed periodic validation from 2 to 5 minutes to avoid conflicts with 3-minute cache
- Added failure counting - only logs out after 3 consecutive failures
- Increased random offset to prevent thundering herd
### 7. Fixed Auth Error Handler
**File**: `plugins/02.auth-error-handler.client.ts`
Updated to only handle 401/403 errors from application endpoints:
```javascript
// Only handle authentication errors from our own API endpoints
const isAuthEndpoint = response.url && (
response.url.includes('/api/auth/') ||
response.url.includes('/api/') && !response.url.includes('cms.portnimara.dev') && !response.url.includes('database.portnimara.com')
)
// Handle authentication errors (401, 403) only from our API
if ((response.status === 401 || response.status === 403) && isAuthEndpoint) {
// Clear auth and redirect
} else if (response.status === 401 && !isAuthEndpoint) {
console.log('[AUTH_ERROR_HANDLER] Ignoring 401 from external service:', response.url)
// Don't clear auth for external service 401s
}
```
This prevents external service authentication errors from logging users out.
## Expected Results
1. **Expenses page should now load properly** for users with sales or admin roles
2. **404 errors won't cause session expiration** - only actual authentication failures (401) will clear the session
3. **Better error handling** - 403 errors (insufficient permissions) will redirect to dashboard with a message instead of logging out
4. **Consistent layout** across all dashboard pages
5. **No immediate logout** - Session checks are properly coordinated and cached
6. **Stable session management** - No conflicts between different auth checking mechanisms
7. **External service errors ignored** - 401 errors from CMS or database won't log users out
## Testing Steps
1. Log in with a user that has sales or admin role
2. Navigate to `/dashboard/expenses`
3. Verify the page loads without 404
4. If you don't have the required role, you should be redirected to dashboard with an error message (not logged out)
5. Try navigating to a non-existent page - you should get a 404 but remain logged in
## Additional Improvements
- The authorization middleware now stores error messages that are displayed via toast
- The authorization middleware uses cached auth state instead of making API calls
- The dashboard layout shows the current user and their role with safe access patterns
- Navigation menu dynamically shows/hides items based on user roles
- Session validation continues to work with the 3-minute cache + jitter to prevent race conditions
- Auth refresh plugin runs validation every 5 minutes to avoid cache conflicts
- Multiple failure tolerance prevents transient issues from logging users out
- Auth error handler differentiates between app and external service errors
## Timing Configuration Summary
- **Session Cache**: 3 minutes (with 0-10 second jitter)
- **Auth Refresh Validation**: Every 5 minutes (with 0-10 second offset)
- **Token Refresh**: 5 minutes before token expiry
- **Failure Tolerance**: 3 consecutive failures before logout
This configuration ensures no timing conflicts between different auth mechanisms.
## External Service Integration
The auth error handler now properly handles errors from external services:
- **CMS errors** (cms.portnimara.dev) - 401 errors are logged but don't trigger logout
- **Database errors** (database.portnimara.com) - 401 errors are logged but don't trigger logout
- **App API errors** (/api/*) - 401/403 errors still trigger logout as expected
This allows the app to gracefully handle authentication failures with integrated services without disrupting the user's main session.

View File

@@ -0,0 +1,310 @@
# Authentication Session Timeout Fix - Deployment Guide
## Overview
This document provides step-by-step instructions for deploying the authentication session timeout fixes that resolve the 2-minute logout issue.
## Problem Summary
Users were experiencing unexpected logouts after exactly 2 minutes when navigating between pages. This was caused by:
1. **Timing Race Condition**: Authentication middleware cache expiry (2 minutes) and auth refresh plugin periodic validation (2 minutes) occurring simultaneously
2. **No Request Deduplication**: Multiple concurrent session checks causing conflicts
3. **Insufficient Error Handling**: Network errors triggering immediate logouts
4. **No Grace Periods**: Transient issues causing permanent session loss
## Solution Overview
### Core Changes
1. **Session Manager Utility** (`server/utils/session-manager.ts`)
- Centralized session management with request deduplication
- Promise caching for in-flight requests
- Network error grace periods
- Comprehensive logging and statistics
2. **Authentication Middleware** (`middleware/authentication.ts`)
- Changed cache expiry from 2 to 3 minutes with jitter
- Integrated SessionManager for deduplication
- Enhanced error handling and user feedback
3. **Auth Refresh Plugin** (`plugins/01.auth-refresh.client.ts`)
- Added random offset to prevent simultaneous validation
- Improved concurrent validation prevention
- Better error handling for network issues
4. **Session API** (`server/api/auth/session.ts`)
- Enhanced logging with request IDs
- Detailed error categorization
- Performance timing measurements
5. **Keycloak Client** (`server/utils/keycloak-client.ts`)
- Better error type distinction
- Increased retry attempts for token refresh
- Improved timeout handling
6. **Refresh API** (`server/api/auth/refresh.ts`)
- Enhanced error handling with request IDs
- Grace period support for transient failures
- Selective session clearing based on error type
## Pre-deployment Checklist
- [ ] **Code Review**: All changes reviewed and approved
- [ ] **Environment Variables**: Verify all required environment variables are set
- [ ] **Dependencies**: Confirm no new dependencies are required
- [ ] **Backup**: Create backup of current production code
- [ ] **Monitoring**: Ensure authentication logs are being captured
- [ ] **Testing**: Verify fixes work in staging environment (if available)
## Deployment Steps
### Step 1: Deploy Session Manager Utility
1. Deploy `server/utils/session-manager.ts`
2. Verify no TypeScript compilation errors
3. Check server logs for any startup issues
### Step 2: Update Authentication Middleware
1. Deploy updated `middleware/authentication.ts`
2. Monitor for any middleware errors in logs
3. Verify new timing configuration is active
### Step 3: Update Auth Refresh Plugin
1. Deploy updated `plugins/01.auth-refresh.client.ts`
2. Check browser console for any client-side errors
3. Verify random offset is working (check logs)
### Step 4: Update Session API
1. Deploy updated `server/api/auth/session.ts`
2. Monitor API endpoint logs for request IDs
3. Verify enhanced error messages are working
### Step 5: Update Keycloak Client
1. Deploy updated `server/utils/keycloak-client.ts`
2. Check for any Keycloak communication errors
3. Verify retry logic is functioning
### Step 6: Update Refresh API
1. Deploy updated `server/api/auth/refresh.ts`
2. Monitor token refresh operations
3. Verify graceful error handling
## Post-deployment Verification
### Immediate Verification (0-5 minutes)
1. **No Deployment Errors**
```bash
# Check server logs
tail -f /var/log/application.log | grep -E "(ERROR|FATAL)"
# Check for any 500 errors
curl -I https://your-domain.com/api/health
```
2. **Login Flow Test**
- Navigate to login page
- Complete authentication
- Verify successful redirect to dashboard
3. **Session API Test**
```bash
# Test session endpoint
curl -X GET https://your-domain.com/api/auth/session \
-H "Cookie: nuxt-oidc-auth=<session-cookie>"
```
### Short-term Verification (5-15 minutes)
1. **Navigation Test**
- Stay logged in for 5+ minutes
- Navigate between different pages
- Verify no unexpected logouts
2. **Log Analysis**
```bash
# Check for new session manager logs
grep "SESSION_MANAGER" /var/log/application.log
# Verify timing desynchronization
grep "Using cached session" /var/log/application.log
```
### Long-term Verification (15+ minutes)
1. **2-Minute Boundary Test**
- Stay logged in for exactly 2 minutes
- Navigate to a new page
- Verify user remains authenticated
2. **3-Minute Cache Test**
- Stay on same page for 3+ minutes
- Navigate to new page
- Verify session is refreshed, not lost
3. **Network Error Simulation**
- Temporarily block network access
- Verify graceful degradation
- Restore network and verify recovery
## Monitoring and Alerts
### Key Metrics to Monitor
1. **Authentication Errors**
```bash
# Monitor auth failure rate
grep -c "AUTH_ERROR" /var/log/application.log
```
2. **Session Manager Performance**
```bash
# Check session check durations
grep "Session check completed" /var/log/application.log
```
3. **Cache Hit Rate**
```bash
# Monitor cache effectiveness
grep "Using cached session" /var/log/application.log | wc -l
```
### Alert Thresholds
- **Auth Error Rate**: > 5% of total auth checks
- **Session Check Duration**: > 2 seconds average
- **Cache Miss Rate**: > 80% (indicates caching issues)
## Rollback Procedures
### Immediate Rollback (if critical issues)
1. **Stop Application**
```bash
systemctl stop your-application
```
2. **Restore Previous Code**
```bash
git checkout previous-stable-tag
npm install
npm run build
```
3. **Restart Application**
```bash
systemctl start your-application
```
4. **Verify Rollback**
- Test login functionality
- Check error logs
- Verify user sessions work
### Partial Rollback (if specific component issues)
1. **Identify Problem Component**
- Check which specific file is causing issues
- Review recent error logs
2. **Rollback Specific Files**
```bash
git checkout HEAD~1 -- middleware/authentication.ts
# or
git checkout HEAD~1 -- server/utils/session-manager.ts
```
3. **Rebuild and Test**
```bash
npm run build
systemctl restart your-application
```
## Troubleshooting
### Common Issues
1. **Users Still Getting Logged Out at 2 Minutes**
- Check if SessionManager is being used
- Verify cache expiry changes are active
- Look for timing synchronization issues
2. **Session Check Errors**
- Check network connectivity to Keycloak
- Verify environment variables are set
- Check Keycloak circuit breaker status
3. **Performance Issues**
- Monitor session check durations
- Check cache hit rates
- Verify request deduplication is working
### Debug Commands
```bash
# Check session manager cache stats
curl https://your-domain.com/api/debug/session-cache-stats
# Monitor real-time auth logs
tail -f /var/log/application.log | grep -E "(SESSION|AUTH_REFRESH|MIDDLEWARE)"
# Check Keycloak connectivity
curl https://your-domain.com/api/debug/test-keycloak-connectivity
```
## Success Criteria
The deployment is considered successful when:
1. **No 2-Minute Logouts**: Users can navigate freely after 2 minutes
2. **Improved Error Handling**: Network issues don't cause immediate logouts
3. **Better Performance**: Session checks complete faster due to caching
4. **Enhanced Logging**: Detailed logs help with debugging future issues
5. **Graceful Degradation**: System handles transient failures elegantly
## Contact Information
For issues or questions regarding this deployment:
- **Technical Lead**: [Your Name]
- **Emergency Contact**: [Emergency Number]
- **Documentation**: This file and related docs in `/docs/` directory
## Appendix
### Environment Variables Required
```env
KEYCLOAK_CLIENT_SECRET=your-secret-key
COOKIE_DOMAIN=.portnimara.dev
```
### Log Examples
Successful session check:
```
[SESSION_MANAGER:abc123] Session check completed: {"authenticated":true,"reason":null,"fromCache":false}
```
Cache hit:
```
[SESSION_MANAGER:def456] Using cached session (age: 45 seconds)
```
Network error with grace period:
```
[SESSION_MANAGER:ghi789] Using cached result due to network error
```
### Performance Benchmarks
- **Session Check Duration**: < 500ms average
- **Cache Hit Rate**: > 70%
- **Authentication Success Rate**: > 99%
- **Network Error Recovery**: < 5 seconds

View File

@@ -0,0 +1,321 @@
<template>
<v-app>
<v-navigation-drawer
v-model="drawer"
:rail="rail"
permanent
color="white"
class="elevation-2"
>
<!-- Logo and Title -->
<v-list>
<v-list-item
class="px-3 py-3 cursor-pointer"
@click="rail = !rail"
>
<template v-slot:prepend>
<v-avatar size="40" class="me-3">
<v-img src="/Port Nimara New Logo-Circular Frame.png" alt="Port Nimara" />
</v-avatar>
</template>
<v-list-item-title v-if="!rail" class="text-subtitle-1 font-weight-medium">
Port Nimara CRM
</v-list-item-title>
</v-list-item>
</v-list>
<v-divider></v-divider>
<!-- Navigation Items -->
<v-list density="compact" nav>
<v-list-item
v-for="item in navigationItems"
:key="item.to"
:prepend-icon="item.icon"
:title="item.label"
:value="item.to"
:to="item.to"
color="primary"
rounded="xl"
class="mx-1"
></v-list-item>
</v-list>
<template v-slot:append>
<v-divider></v-divider>
<!-- User Info Section -->
<div class="pa-2">
<v-list v-if="authState?.user">
<v-list-item
:prepend-avatar="`https://ui-avatars.com/api/?name=${encodeURIComponent(authState.user.name || authState.user.email)}&background=387bca&color=fff`"
:title="authState.user.name || authState.user.email"
:subtitle="authState.user.email"
class="px-2"
>
<template v-slot:append v-if="!rail && authState?.groups?.length">
<div>
<v-chip
v-if="authState.groups.includes('admin')"
size="small"
color="orange"
variant="tonal"
>
Admin
</v-chip>
<v-chip
v-else-if="authState.groups.includes('sales')"
size="small"
color="green"
variant="tonal"
>
Sales
</v-chip>
</div>
</template>
</v-list-item>
<v-list-item
@click="handleLogout"
prepend-icon="mdi-logout"
title="Logout"
class="px-2 mt-1"
base-color="error"
rounded="xl"
></v-list-item>
</v-list>
</div>
</template>
</v-navigation-drawer>
<v-app-bar
flat
color="white"
class="border-b"
>
<v-app-bar-nav-icon
@click="drawer = !drawer"
class="d-lg-none"
></v-app-bar-nav-icon>
<v-toolbar-title class="text-h6">
{{ pageTitle }}
</v-toolbar-title>
<v-spacer></v-spacer>
</v-app-bar>
<v-main class="bg-grey-lighten-4">
<slot />
</v-main>
</v-app>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useDisplay } from 'vuetify';
const route = useRoute();
const router = useRouter();
const nuxtApp = useNuxtApp();
const { mdAndDown } = useDisplay();
// Sidebar state
const drawer = ref(true);
const rail = ref(false);
// Get auth state - with fallback to prevent errors
const authState = computed(() => {
const data = nuxtApp.payload?.data?.authState;
// Only return data if it's properly initialized
if (data && data.authenticated !== undefined) {
return data;
}
return null;
});
// Page title based on current route
const pageTitle = computed(() => {
const routeName = route.name as string;
const pageTitles: Record<string, string> = {
'dashboard': 'Dashboard',
'dashboard-index': 'Dashboard',
'dashboard-expenses': 'Expense Tracking',
'dashboard-interest-list': 'Interest List',
'dashboard-berth-list': 'Berth List',
'dashboard-interest-status': 'Interest Status',
'dashboard-interest-emails': 'Interest Emails',
'dashboard-interest-berth-list': 'Interest Berth List',
'dashboard-interest-berth-status': 'Berth Status',
'dashboard-interest-analytics': 'Analytics',
'dashboard-file-browser': 'File Browser',
'dashboard-admin': 'Admin Console',
'dashboard-admin-index': 'Admin Console',
'dashboard-admin-audit-logs': 'Audit Logs',
'dashboard-admin-system-logs': 'System Logs',
'dashboard-admin-duplicates': 'Duplicate Management',
'dashboard-sidebar-demo': 'Sidebar Demo',
};
return pageTitles[routeName] || 'Dashboard';
});
// Navigation items based on user role
const navigationItems = computed(() => {
const items = [
{
label: 'Interest List',
icon: 'mdi-account-multiple',
to: '/dashboard/interest-list',
},
{
label: 'Analytics',
icon: 'mdi-chart-bar',
to: '/dashboard/interest-analytics',
},
{
label: 'Berth List',
icon: 'mdi-table',
to: '/dashboard/interest-berth-list',
},
{
label: 'Berth Status',
icon: 'mdi-map',
to: '/dashboard/interest-berth-status',
},
{
label: 'Interest Status',
icon: 'mdi-clipboard-check',
to: '/dashboard/interest-status',
},
{
label: 'File Browser',
icon: 'mdi-folder-open',
to: '/dashboard/file-browser',
},
];
// Add sales/admin specific items
if (authState.value?.groups?.includes('sales') || authState.value?.groups?.includes('admin')) {
items.push(
{
label: 'Expenses',
icon: 'mdi-receipt',
to: '/dashboard/expenses',
}
);
}
// Add admin-only items
if (authState.value?.groups?.includes('admin')) {
items.push({
label: 'Admin Console',
icon: 'mdi-shield-crown',
to: '/dashboard/admin',
});
}
return items;
});
// Logout handler
const handleLogout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' });
await router.push('/login');
} catch (error) {
console.error('Logout error:', error);
// Even if logout fails, redirect to login
await router.push('/login');
}
};
// Initialize drawer state on mobile
onMounted(() => {
if (mdAndDown.value) {
drawer.value = false;
}
});
</script>
<style scoped>
.border-b {
border-bottom: 1px solid rgba(0, 0, 0, 0.12) !important;
}
.cursor-pointer {
cursor: pointer;
}
/* Improve rail mode appearance */
.v-navigation-drawer--rail {
width: 72px !important;
}
.v-navigation-drawer--rail .v-list-item {
padding-inline-start: 12px !important;
padding-inline-end: 12px !important;
}
.v-navigation-drawer--rail .v-list-item__prepend {
margin-inline-end: 0 !important;
}
.v-navigation-drawer--rail .v-list-item__append {
display: none;
}
.v-navigation-drawer--rail.v-navigation-drawer--is-hovering .v-list-item__append {
display: flex;
}
/* Ensure proper mobile responsiveness */
@media (max-width: 960px) {
.v-navigation-drawer {
position: fixed !important;
z-index: 1004 !important;
}
.v-main {
padding-left: 0 !important;
}
}
/* PWA optimizations */
@media (display-mode: standalone) {
.v-app-bar {
padding-top: env(safe-area-inset-top);
}
.v-navigation-drawer {
padding-bottom: env(safe-area-inset-bottom);
}
}
/* Improve visual alignment */
.v-list-item__prepend > .v-avatar {
margin-inline-end: 16px;
}
.v-navigation-drawer--rail .v-list-item__prepend > .v-avatar {
margin-inline-end: 0;
}
/* Center logo in rail mode */
.v-navigation-drawer--rail .v-list-item {
justify-content: center;
padding-inline-start: 16px !important;
padding-inline-end: 16px !important;
}
.v-navigation-drawer--rail .v-list-item__prepend {
justify-content: center;
}
/* Smooth transitions */
.v-navigation-drawer,
.v-list-item__content,
.v-list-item__prepend {
transition: all 0.2s ease-in-out;
}
</style>

View File

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

View File

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

View File

@@ -79,6 +79,7 @@ export default defineNuxtConfig({
workbox: {
navigateFallback: '/',
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
navigateFallbackDenylist: [/^\/api\//],
runtimeCaching: [
{
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
@@ -94,7 +95,9 @@ export default defineNuxtConfig({
}
}
}
]
],
skipWaiting: true,
clientsClaim: true
},
client: {
installPrompt: true,

3535
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/ui": "^3.2.0",
"@pdfme/common": "^5.4.0",
"@pdfme/generator": "^5.4.0",
"@types/lodash-es": "^4.17.12",
@@ -24,6 +25,7 @@
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"pdfkit": "^0.17.1",
"sharp": "^0.34.2",
"v-phone-input": "^4.4.2",
"vue": "latest",
@@ -35,6 +37,7 @@
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17"
"@types/nodemailer": "^6.4.17",
"@types/pdfkit": "^0.14.0"
}
}

View File

@@ -1,293 +1,13 @@
<template>
<v-app full-height>
<v-navigation-drawer
v-model="drawer"
:location="mdAndDown ? 'bottom' : undefined"
>
<v-img v-if="!mdAndDown" src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="110" class="my-6" contain />
<v-list color="primary" lines="two">
<v-list-item
v-for="(item, index) in safeMenu"
:key="index"
:to="item.to"
:title="item.title"
:prepend-icon="item.icon"
/>
</v-list>
<template #append>
<v-list lines="two">
<v-list-item
v-if="user"
:title="user.name"
:subtitle="user.email"
prepend-icon="mdi-account"
>
<template #append>
<v-chip v-if="user.tier && user.tier !== 'basic'" size="small" color="primary">
{{ user.tier }}
</v-chip>
</template>
</v-list-item>
<v-list-item
@click="logOut"
title="Log out"
prepend-icon="mdi-logout"
base-color="error"
/>
</v-list>
</template>
</v-navigation-drawer>
<v-app-bar v-if="mdAndDown" elevation="2">
<template #prepend>
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer" />
</template>
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="50" />
<template #append>
<v-btn
@click="logOut"
class="mr-3"
variant="text"
color="error"
icon="mdi-logout"
/>
</template>
</v-app-bar>
<v-main>
<router-view />
</v-main>
</v-app>
<div>
<!-- This page now acts as a parent route for dashboard pages -->
<NuxtPage />
</div>
</template>
<script setup>
definePageMeta({
middleware: ["authentication"],
layout: false,
layout: "dashboard-unified",
});
const { mdAndDown } = useDisplay();
const { user, logout, authSource } = useUnifiedAuth();
const { isAdmin, getUserGroups, getCurrentUser } = useAuthorization();
const tags = usePortalTags();
const drawer = ref(false);
// Debug auth state
onMounted(() => {
nextTick(() => {
console.log('[Dashboard] Auth state on mount:', {
isAdmin: isAdmin(),
userGroups: getUserGroups(),
currentUser: getCurrentUser()
});
});
});
const interestMenu = computed(() => {
const userIsAdmin = isAdmin();
const userGroups = getUserGroups();
console.log('[Dashboard] Computing interest menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
// Check if user has sales or admin privileges
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
const baseMenu = [
//{
// to: "/dashboard/interest-eoi-queue",
// icon: "mdi-tray-full",
// title: "EOI Queue",
//},
{
to: "/dashboard/interest-analytics",
icon: "mdi-view-dashboard",
title: "Analytics",
},
{
to: "/dashboard/interest-berth-list",
icon: "mdi-table",
title: "Berth List",
},
{
to: "/dashboard/interest-berth-status",
icon: "mdi-sail-boat",
title: "Berth Status",
},
{
to: "/dashboard/interest-list",
icon: "mdi-view-list",
title: "Interest List",
},
{
to: "/dashboard/interest-status",
icon: "mdi-account-check",
title: "Interest Status",
},
{
to: "/dashboard/file-browser",
icon: "mdi-folder",
title: "File Browser",
},
];
// Only show expenses to sales and admin users
if (hasSalesAccess) {
console.log('[Dashboard] Adding expenses to menu (user has sales/admin access)');
baseMenu.push({
to: "/dashboard/expenses",
icon: "mdi-receipt",
title: "Expenses",
});
} else {
console.log('[Dashboard] Hiding expenses from menu (user role:', userGroups, ')');
}
// Add admin menu items if user is admin
if (userIsAdmin) {
console.log('[Dashboard] Adding admin console to interest menu');
baseMenu.push({
to: "/dashboard/admin",
icon: "mdi-shield-crown",
title: "Admin Console",
});
}
return baseMenu;
});
const defaultMenu = computed(() => {
const userIsAdmin = isAdmin();
const userGroups = getUserGroups();
console.log('[Dashboard] Computing default menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
const baseMenu = [
{
to: "/dashboard/site",
icon: "mdi-view-dashboard",
title: "Site Analytics",
},
{
to: "/dashboard/data",
icon: "mdi-finance",
title: "Data Analytics",
},
{
to: "/dashboard/file-browser",
icon: "mdi-folder",
title: "File Browser",
},
];
// Add admin menu items if user is admin
if (userIsAdmin) {
console.log('[Dashboard] Adding admin console to default menu');
baseMenu.push({
to: "/dashboard/admin",
icon: "mdi-shield-crown",
title: "Admin Console",
});
}
return baseMenu;
});
const menu = computed(() => {
try {
const tagsValue = toValue(tags);
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
console.log('[Dashboard] Computing menu:', {
hasInterestTag: tagsValue.interest,
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
menuIsArray: Array.isArray(menuToUse),
menuLength: menuToUse?.length
});
return menuToUse;
} catch (error) {
console.error('[Dashboard] Error computing menu:', error);
return [];
}
});
// Safe menu wrapper to prevent crashes when menu is undefined
const safeMenu = computed(() => {
try {
const currentMenu = menu.value;
if (Array.isArray(currentMenu)) {
return currentMenu;
}
console.warn('[Dashboard] Menu is not an array, returning fallback menu');
// Get current user permissions for fallback menu
const userIsAdmin = isAdmin();
const userGroups = getUserGroups();
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
// Fallback menu with essential items (respecting permissions)
const fallbackMenu = [
{
to: "/dashboard/interest-list",
icon: "mdi-view-list",
title: "Interest List",
},
{
to: "/dashboard/file-browser",
icon: "mdi-folder",
title: "File Browser",
},
];
// Only add expenses if user has sales/admin access
if (hasSalesAccess) {
fallbackMenu.push({
to: "/dashboard/expenses",
icon: "mdi-receipt",
title: "Expenses",
});
}
// Only add admin console if user is admin
if (userIsAdmin) {
fallbackMenu.push({
to: "/dashboard/admin",
icon: "mdi-shield-crown",
title: "Admin Console",
});
}
return fallbackMenu;
} catch (error) {
console.error('[Dashboard] Error computing menu:', error);
// Emergency fallback menu - only essential items
return [
{
to: "/dashboard/interest-list",
icon: "mdi-view-list",
title: "Interest List",
},
];
}
});
const logOut = async () => {
await logout();
return navigateTo("/login");
};
onMounted(() => {
if (mdAndDown.value) {
return;
}
drawer.value = true;
})
</script>

View File

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

View File

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

View File

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

View File

@@ -24,7 +24,7 @@
<v-card class="mb-6">
<v-card-text class="pa-6">
<v-row align="center" class="mb-0">
<v-col cols="12" sm="6" md="3">
<v-col cols="12" sm="6" md="2">
<v-text-field
v-model="filters.startDate"
type="date"
@@ -32,11 +32,11 @@
variant="outlined"
density="comfortable"
hide-details
@change="fetchExpenses"
class="date-input-fix"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-col cols="12" sm="6" md="2">
<v-text-field
v-model="filters.endDate"
type="date"
@@ -44,11 +44,11 @@
variant="outlined"
density="comfortable"
hide-details
@change="fetchExpenses"
class="date-input-fix"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-col cols="12" sm="6" md="2">
<v-select
v-model="filters.category"
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
@@ -57,15 +57,27 @@
density="comfortable"
hide-details
clearable
@update:model-value="fetchExpenses"
/>
</v-col>
<v-col cols="12" sm="6" md="3">
<v-col cols="12" sm="6" md="2">
<v-btn
@click="fetchExpenses"
color="primary"
variant="flat"
size="large"
class="w-100"
prepend-icon="mdi-magnify"
>
Apply
</v-btn>
</v-col>
<v-col cols="12" sm="6" md="2">
<v-btn
@click="resetToCurrentMonth"
variant="outlined"
size="large"
size="default"
class="w-100"
>
Current Month
@@ -187,7 +199,7 @@
<div class="d-flex flex-wrap align-center">
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
<div class="d-flex gap-4">
<div class="d-flex ga-4">
<v-btn
@click="exportCSV"
:disabled="selectedExpenses.length === 0"
@@ -291,6 +303,36 @@
v-model="showCreateModal"
@created="handleExpenseCreated"
/>
<!-- PDF Generation Loading Overlay -->
<v-overlay
:model-value="generatingPDF"
persistent
class="align-center justify-center"
>
<v-card
color="surface"
class="pa-8"
width="400"
>
<div class="text-center">
<v-progress-circular
:size="70"
:width="7"
color="primary"
indeterminate
/>
<h3 class="text-h6 mt-4 mb-2">Generating PDF...</h3>
<p class="text-body-2 text-grey-darken-1">
Your expense report is being generated with receipt images
</p>
<p class="text-caption text-grey-darken-1 mt-2">
This may take a moment for large reports
</p>
</div>
</v-card>
</v-overlay>
</div>
</template>
@@ -306,8 +348,9 @@ const ExpenseCreateModal = defineAsyncComponent(() => import('@/components/Expen
// Page meta
definePageMeta({
middleware: ['authentication'],
layout: 'dashboard'
middleware: ['authentication', 'authorization'],
layout: 'dashboard-unified',
roles: ['sales', 'admin']
});
useHead({
@@ -324,6 +367,7 @@ const showDetailsModal = ref(false);
const showCreateModal = ref(false);
const selectedExpense = ref<Expense | null>(null);
const activeTab = ref<string>('');
const generatingPDF = ref(false);
// Filters
const filters = ref({
@@ -413,7 +457,17 @@ const fetchExpenses = async () => {
} catch (err: any) {
console.error('[expenses] Error fetching expenses:', err);
error.value = err.message || 'Failed to fetch expenses';
// Better error messages based on status codes
if (err.statusCode === 401) {
error.value = 'Authentication required. Please refresh the page and log in again.';
} else if (err.statusCode === 403) {
error.value = 'Access denied. You need proper permissions to view expenses.';
} else if (err.statusCode === 503) {
error.value = 'Service temporarily unavailable. Please try again in a few moments.';
} else {
error.value = err.data?.message || err.message || 'Failed to fetch expenses. Please check your connection and try again.';
}
} finally {
loading.value = false;
}
@@ -484,6 +538,9 @@ const exportCSV = async () => {
};
const generatePDF = async (options: any) => {
generatingPDF.value = true;
showPDFModal.value = false; // Close the modal immediately
try {
console.log('[expenses] Generating PDF with options:', options);
@@ -504,30 +561,33 @@ const generatePDF = async (options: any) => {
});
if (response.success && response.data) {
// For now, create HTML file instead of PDF since we're generating HTML content
const htmlContent = atob(response.data.content); // Decode base64
const blob = new Blob([htmlContent], { type: 'text/html' });
// Decode base64 PDF content
const pdfContent = atob(response.data.content);
// Convert to byte array
const byteNumbers = new Array(pdfContent.length);
for (let i = 0; i < pdfContent.length; i++) {
byteNumbers[i] = pdfContent.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// Create PDF blob and download
const blob = new Blob([byteArray], { type: 'application/pdf' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${options.documentName || 'expenses'}.html`;
a.download = response.data.filename;
a.click();
window.URL.revokeObjectURL(url);
// Also open in new tab for immediate viewing
const newTab = window.open();
if (newTab) {
newTab.document.open();
newTab.document.write(htmlContent);
newTab.document.close();
}
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
}
showPDFModal.value = false;
} catch (err: any) {
console.error('[expenses] Error generating PDF:', err);
error.value = err.message || 'Failed to generate PDF';
} finally {
generatingPDF.value = false;
}
};
@@ -602,4 +662,10 @@ onMounted(async () => {
.v-tab {
text-transform: none !important;
}
/* Fix for date input calendar button positioning */
.date-input-fix :deep(.v-field__append-inner) {
padding-inline-start: 8px;
margin-inline-end: 4px;
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
<template>
<div>
<v-card>
<v-card-title class="text-h4">
New Unified Sidebar Demo
</v-card-title>
<v-card-text>
<v-alert type="success" variant="tonal" class="mb-6">
<div class="text-h6 mb-2"> Modern Sidebar Features</div>
<ul class="pl-4">
<li>Clean white design with subtle borders</li>
<li>Collapsible sidebar with smooth animations</li>
<li>Icons that change color when active</li>
<li>User info with avatar at the bottom</li>
<li>Role badges (Admin/Sales)</li>
<li>Responsive - becomes a drawer on mobile</li>
<li>Page title in the top navbar</li>
</ul>
</v-alert>
<v-row>
<v-col cols="12" md="6">
<v-card variant="outlined">
<v-card-title>How to Use</v-card-title>
<v-card-text>
<p class="mb-3">Click the chevron icon in the sidebar header to collapse/expand it.</p>
<p class="mb-3">On mobile devices, use the hamburger menu to toggle the sidebar.</p>
<p>The sidebar automatically adjusts based on your role permissions.</p>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="6">
<v-card variant="outlined">
<v-card-title>Navigation Items</v-card-title>
<v-card-text>
<p class="mb-3">The sidebar shows different menu items based on your role:</p>
<ul class="pl-4">
<li><strong>All users:</strong> Dashboard, Analytics, Berth List, Interest List, File Browser</li>
<li><strong>Sales/Admin:</strong> + Expenses, Interest Emails</li>
<li><strong>Admin only:</strong> + Admin Console</li>
</ul>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-alert type="info" variant="tonal" class="mt-6">
<div class="text-subtitle-1 font-weight-medium mb-2">Technical Details</div>
<p class="text-body-2">This sidebar uses Nuxt UI's <code>UDashboardSidebar</code> component with custom styling to match your brand colors and maintain a clean, professional look.</p>
</v-alert>
</v-card-text>
</v-card>
</div>
</template>
<script setup>
definePageMeta({
middleware: ['authentication'],
layout: 'dashboard-unified'
});
useHead({
title: 'Sidebar Demo'
});
</script>

View File

@@ -4,6 +4,8 @@ export default defineNuxtPlugin(() => {
let refreshTimer: NodeJS.Timeout | null = null
let isRefreshing = false
let retryCount = 0
const maxRetries = 3
const scheduleTokenRefresh = (expiresAt: number) => {
// Clear existing timer
@@ -12,11 +14,13 @@ export default defineNuxtPlugin(() => {
refreshTimer = null
}
// Calculate time until refresh (refresh 2 minutes before expiry)
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
// Calculate time until refresh (refresh 5 minutes before expiry)
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
console.log('[AUTH_REFRESH] Token expires at:', new Date(expiresAt))
console.log('[AUTH_REFRESH] Will refresh at:', new Date(expiresAt - refreshBuffer))
// Only schedule if we have time left
if (timeUntilRefresh > 0) {
@@ -28,20 +32,37 @@ export default defineNuxtPlugin(() => {
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
method: 'POST'
method: 'POST',
retry: 2,
retryDelay: 1000
})
if (response.success && response.expiresAt) {
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
retryCount = 0 // Reset retry count on success
scheduleTokenRefresh(response.expiresAt)
} else {
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
await navigateTo('/login')
}
} catch (error) {
} catch (error: any) {
console.error('[AUTH_REFRESH] Token refresh error:', error)
// If refresh fails, redirect to login
await navigateTo('/login')
// Implement exponential backoff retry
if (retryCount < maxRetries) {
retryCount++
const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 10000) // Max 10 seconds
console.log(`[AUTH_REFRESH] Retrying refresh in ${retryDelay}ms (attempt ${retryCount}/${maxRetries})`)
setTimeout(() => {
if (!isRefreshing) {
scheduleTokenRefresh(expiresAt)
}
}, retryDelay)
} else {
console.error('[AUTH_REFRESH] Max retries reached, redirecting to login')
await navigateTo('/login')
}
} finally {
isRefreshing = false
}
@@ -56,11 +77,14 @@ export default defineNuxtPlugin(() => {
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
method: 'POST'
method: 'POST',
retry: 2,
retryDelay: 1000
})
if (response.success && response.expiresAt) {
console.log('[AUTH_REFRESH] Immediate refresh successful')
retryCount = 0 // Reset retry count on success
scheduleTokenRefresh(response.expiresAt)
} else {
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
@@ -68,7 +92,19 @@ export default defineNuxtPlugin(() => {
}
} catch (error) {
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
await navigateTo('/login')
// Try one more time before giving up
if (retryCount === 0) {
retryCount++
console.log('[AUTH_REFRESH] Retrying immediate refresh once more...')
setTimeout(() => {
if (!isRefreshing) {
scheduleTokenRefresh(Date.now() - 1) // Force immediate refresh
}
}, 2000)
} else {
await navigateTo('/login')
}
} finally {
isRefreshing = false
}
@@ -127,19 +163,117 @@ export default defineNuxtPlugin(() => {
// Listen for visibility changes to refresh when tab becomes active
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
let lastVisibilityChange = Date.now()
document.addEventListener('visibilitychange', async () => {
if (!document.hidden) {
// Tab became visible, check if we need to refresh
checkAndScheduleRefresh()
const now = Date.now()
const timeSinceLastCheck = now - lastVisibilityChange
// If tab was hidden for more than 30 seconds, check auth status
if (timeSinceLastCheck > 30000) {
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
// Force immediate session validation
try {
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
if (!response.ok || response.status === 401) {
console.log('[AUTH_REFRESH] Session expired while tab was hidden')
await navigateTo('/login')
return
}
const sessionData = await response.json()
if (!sessionData.authenticated) {
console.log('[AUTH_REFRESH] Not authenticated after tab visibility')
await navigateTo('/login')
return
}
// Re-schedule refresh if session is valid
checkAndScheduleRefresh()
} catch (error) {
console.error('[AUTH_REFRESH] Failed to check session on visibility change:', error)
await navigateTo('/login')
}
}
lastVisibilityChange = now
}
})
}
// Clean up timer on plugin destruction
// Add periodic session validation (every 5 minutes instead of 2)
let validationInterval: NodeJS.Timeout | null = null
let isValidating = false // Prevent concurrent validations
let failureCount = 0 // Track consecutive failures
onMounted(() => {
// Add random offset to prevent all clients checking at once
const randomOffset = Math.floor(Math.random() * 10000) // 0-10 seconds
setTimeout(() => {
validationInterval = setInterval(async () => {
if (isValidating) return // Skip if already validating
isValidating = true
console.log('[AUTH_REFRESH] Performing periodic session validation')
try {
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
if (!response.ok || response.status === 401) {
failureCount++
console.log(`[AUTH_REFRESH] Session check failed (attempt ${failureCount}/3)`)
// Only logout after 3 consecutive failures
if (failureCount >= 3) {
console.log('[AUTH_REFRESH] Session invalid after 3 attempts, redirecting to login')
clearInterval(validationInterval!)
await navigateTo('/login')
}
} else {
// Reset failure count on success
failureCount = 0
}
} catch (error) {
console.error('[AUTH_REFRESH] Periodic validation error:', error)
// Don't logout on network errors - let middleware handle it
// But count it as a failure for resilience
failureCount++
if (failureCount >= 3) {
console.log('[AUTH_REFRESH] Too many validation errors, redirecting to login')
clearInterval(validationInterval!)
await navigateTo('/login')
}
} finally {
isValidating = false
}
}, 5 * 60 * 1000) // Changed to 5 minutes to avoid conflicts with 3-minute cache
}, randomOffset)
})
// Clean up timers on plugin destruction
onBeforeUnmount(() => {
if (refreshTimer) {
clearTimeout(refreshTimer)
refreshTimer = null
}
if (validationInterval) {
clearInterval(validationInterval)
validationInterval = null
}
})
})

View File

@@ -0,0 +1,129 @@
export default defineNuxtPlugin(() => {
// Only run on client side
if (import.meta.server) return
const nuxtApp = useNuxtApp()
const toast = useToast()
// Global error handler for API requests
nuxtApp.hook('app:error', (error: any) => {
console.error('[AUTH_ERROR_HANDLER] Application error:', error)
// Handle authentication errors
if (error.statusCode === 401 || error.statusCode === 403) {
handleAuthError(error)
}
})
// Intercept $fetch errors globally
const originalFetch = globalThis.$fetch
globalThis.$fetch = $fetch.create({
onResponseError({ response }) {
console.log('[AUTH_ERROR_HANDLER] Response error:', {
status: response.status,
url: response.url,
statusText: response.statusText
})
// Only handle authentication errors from our own API endpoints
const isAuthEndpoint = response.url && (
response.url.includes('/api/auth/') ||
response.url.includes('/api/') && !response.url.includes('cms.portnimara.dev') && !response.url.includes('database.portnimara.com')
)
// Handle authentication errors (401, 403) only from our API
if ((response.status === 401 || response.status === 403) && isAuthEndpoint) {
console.log('[AUTH_ERROR_HANDLER] Authentication error from app endpoint')
handleAuthError({
statusCode: response.status,
statusMessage: response.statusText,
data: response._data
})
} else if (response.status === 401 && !isAuthEndpoint) {
console.log('[AUTH_ERROR_HANDLER] Ignoring 401 from external service:', response.url)
// Don't clear auth for external service 401s
}
// Handle 404 errors that might be auth-related
if (response.status === 404 && isProtectedRoute() && isAuthEndpoint) {
console.warn('[AUTH_ERROR_HANDLER] 404 on protected route from app endpoint, may be auth-related')
// Check if session is still valid
checkAndHandleSession()
}
}
})
const handleAuthError = async (error: any) => {
console.error('[AUTH_ERROR_HANDLER] Authentication error detected:', error)
// Clear all auth-related caches
clearAuthCaches()
// Only show toast and redirect if we're not already on the login page
const route = useRoute()
if (route.path !== '/login' && !route.path.startsWith('/auth')) {
toast.error('Your session has expired. Please log in again.')
// Delay navigation slightly to ensure toast is visible
setTimeout(() => {
navigateTo('/login')
}, 500)
}
}
const clearAuthCaches = () => {
console.log('[AUTH_ERROR_HANDLER] Clearing authentication caches')
// Clear Nuxt app payload caches
if (nuxtApp.payload.data) {
delete nuxtApp.payload.data['auth:session:cache']
delete nuxtApp.payload.data.authState
}
// Clear session cookie
const sessionCookie = useCookie('nuxt-oidc-auth')
sessionCookie.value = null
}
const isProtectedRoute = () => {
const route = useRoute()
// Check if current route requires authentication
return route.meta.auth !== false &&
!route.path.startsWith('/login') &&
!route.path.startsWith('/auth')
}
const checkAndHandleSession = async () => {
try {
// Force a fresh session check without cache
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
if (!response.ok) {
throw new Error(`Session check failed: ${response.status}`)
}
const sessionData = await response.json()
if (!sessionData.authenticated) {
handleAuthError({
statusCode: 401,
statusMessage: 'Session expired'
})
}
} catch (error) {
console.error('[AUTH_ERROR_HANDLER] Failed to check session:', error)
handleAuthError({
statusCode: 401,
statusMessage: 'Session check failed'
})
}
}
// Expose clearAuthCaches for manual use
nuxtApp.provide('clearAuthCaches', clearAuthCaches)
})

2
remove.txt Normal file
View File

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

View File

@@ -1,8 +1,9 @@
import { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth';
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
export default defineEventHandler(async (event) => {
console.log('[DUPLICATES] Find duplicates request');
console.log('[ADMIN] Find duplicates request');
try {
// Require sales or admin access for duplicate detection
@@ -26,17 +27,27 @@ export default defineEventHandler(async (event) => {
const interests = response.list || [];
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
// Find potential duplicates
const duplicateGroups = findDuplicateInterests(interests, threshold);
// Find duplicate groups using the new centralized utility
const duplicateConfig = createInterestConfig();
const duplicateGroups = findDuplicates(interests, duplicateConfig);
// Convert to the expected format
const formattedGroups = duplicateGroups.map(group => ({
id: group.id,
interests: group.items,
matchReason: group.matchReason,
confidence: group.confidence,
masterCandidate: group.masterCandidate
}));
console.log('[ADMIN] Found', duplicateGroups.length, 'duplicate groups');
console.log('[ADMIN] Found', formattedGroups.length, 'duplicate groups');
return {
success: true,
data: {
duplicateGroups,
duplicateGroups: formattedGroups,
totalInterests: interests.length,
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
threshold
}
};
@@ -57,203 +68,3 @@ export default defineEventHandler(async (event) => {
};
}
});
/**
* Find duplicate interests based on multiple criteria
*/
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
const duplicateGroups: Array<{
id: string;
interests: any[];
matchReason: string;
confidence: number;
masterCandidate: any;
}> = [];
const processedIds = new Set<number>();
for (let i = 0; i < interests.length; i++) {
const interest1 = interests[i];
if (processedIds.has(interest1.Id)) continue;
const matches = [interest1];
for (let j = i + 1; j < interests.length; j++) {
const interest2 = interests[j];
if (processedIds.has(interest2.Id)) continue;
const similarity = calculateSimilarity(interest1, interest2);
if (similarity.score >= threshold) {
matches.push(interest2);
processedIds.add(interest2.Id);
}
}
if (matches.length > 1) {
// Mark all as processed
matches.forEach(match => processedIds.add(match.Id));
// Determine the best master candidate (most complete record)
const masterCandidate = selectMasterCandidate(matches);
duplicateGroups.push({
id: `group_${duplicateGroups.length + 1}`,
interests: matches,
matchReason: 'Multiple matching criteria',
confidence: Math.max(...matches.slice(1).map(match =>
calculateSimilarity(masterCandidate, match).score
)),
masterCandidate
});
}
}
return duplicateGroups;
}
/**
* Calculate similarity between two interests
*/
function calculateSimilarity(interest1: any, interest2: any) {
const scores: Array<{ type: string; score: number; weight: number }> = [];
// Email similarity (highest weight)
if (interest1['Email Address'] && interest2['Email Address']) {
const emailScore = interest1['Email Address'].toLowerCase() === interest2['Email Address'].toLowerCase() ? 1.0 : 0.0;
scores.push({ type: 'email', score: emailScore, weight: 0.4 });
}
// Phone similarity
if (interest1['Phone Number'] && interest2['Phone Number']) {
const phone1 = normalizePhone(interest1['Phone Number']);
const phone2 = normalizePhone(interest2['Phone Number']);
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
}
// Name similarity
if (interest1['Full Name'] && interest2['Full Name']) {
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
scores.push({ type: 'name', score: nameScore, weight: 0.2 });
}
// Address similarity
if (interest1.Address && interest2.Address) {
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
}
// Calculate weighted average
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
return {
score: weightedScore,
details: scores
};
}
/**
* Normalize phone number for comparison
*/
function normalizePhone(phone: string): string {
return phone.replace(/\D/g, ''); // Remove all non-digits
}
/**
* Calculate name similarity using Levenshtein distance
*/
function calculateNameSimilarity(name1: string, name2: string): number {
const str1 = name1.toLowerCase().trim();
const str2 = name2.toLowerCase().trim();
if (str1 === str2) return 1.0;
const distance = levenshteinDistance(str1, str2);
const maxLength = Math.max(str1.length, str2.length);
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
}
/**
* Calculate string similarity using Levenshtein distance
*/
function calculateStringSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase().trim();
const s2 = str2.toLowerCase().trim();
if (s1 === s2) return 1.0;
const distance = levenshteinDistance(s1, s2);
const maxLength = Math.max(s1.length, s2.length);
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i += 1) {
matrix[0][i] = i;
}
for (let j = 0; j <= str2.length; j += 1) {
matrix[j][0] = j;
}
for (let j = 1; j <= str2.length; j += 1) {
for (let i = 1; i <= str1.length; i += 1) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // deletion
matrix[j - 1][i] + 1, // insertion
matrix[j - 1][i - 1] + indicator // substitution
);
}
}
return matrix[str2.length][str1.length];
}
/**
* Select the best master candidate from a group of duplicates
*/
function selectMasterCandidate(interests: any[]) {
return interests.reduce((best, current) => {
const bestScore = calculateCompletenessScore(best);
const currentScore = calculateCompletenessScore(current);
return currentScore > bestScore ? current : best;
});
}
/**
* Calculate completeness score for an interest record
*/
function calculateCompletenessScore(interest: any): number {
const fields = ['Full Name', 'Email Address', 'Phone Number', 'Address', 'Extra Comments', 'Berth Size Desired'];
const filledFields = fields.filter(field =>
interest[field] && interest[field].toString().trim().length > 0
);
let score = filledFields.length / fields.length;
// Bonus for recent creation
if (interest['Created At']) {
const created = new Date(interest['Created At']);
const now = new Date();
const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
// More recent records get a small bonus
if (daysOld < 30) score += 0.1;
else if (daysOld < 90) score += 0.05;
}
return score;
}

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,10 @@ export default defineEventHandler(async (event) => {
// Documenso API configuration - moved to top for use throughout
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
const templateId = '9';
const templateId = process.env.NUXT_DOCUMENSO_TEMPLATE_ID || '1';
const clientRecipientId = parseInt(process.env.NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID || '1');
const davidRecipientId = parseInt(process.env.NUXT_DOCUMENSO_DAVID_RECIPIENT_ID || '2');
const approvalRecipientId = parseInt(process.env.NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID || '3');
if (!documensoApiKey || !documensoBaseUrl) {
throw createError({
@@ -231,7 +234,7 @@ export default defineEventHandler(async (event) => {
message: `Dear ${interest['Full Name']},\n\nThank you for your interest in a berth at Port Nimara. Please click the link above to sign your LOI.\n\nBest Regards,\nPort Nimara Team`,
subject: "Your LOI is ready to be signed",
redirectUrl: "https://portnimara.com",
distributionMethod: "SEQUENTIAL"
distributionMethod: "NONE"
},
title: `${interest['Full Name']}-EOI-NDA`,
externalId: `loi-${interestId}`,
@@ -249,22 +252,22 @@ export default defineEventHandler(async (event) => {
},
recipients: [
{
id: 155,
id: clientRecipientId,
name: interest['Full Name'],
role: "SIGNER",
email: interest['Email Address'],
signingOrder: 1
},
{
id: 156,
id: davidRecipientId,
name: "David Mizrahi",
role: "SIGNER",
email: "dm@portnimara.com",
signingOrder: 3
},
{
id: 157,
name: "Oscar Faragher",
id: approvalRecipientId,
name: "Approval",
role: "APPROVER",
email: "sales@portnimara.com",
signingOrder: 2
@@ -337,7 +340,7 @@ export default defineEventHandler(async (event) => {
} else if (recipient.email === 'dm@portnimara.com') {
signingLinks['David Mizrahi'] = recipient.signingUrl;
} else if (recipient.email === 'sales@portnimara.com') {
signingLinks['Oscar Faragher'] = recipient.signingUrl;
signingLinks['Approval'] = recipient.signingUrl;
}
}
});
@@ -392,11 +395,11 @@ export default defineEventHandler(async (event) => {
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
}
if (signingLinks['Oscar Faragher']) {
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
if (signingLinks['Approval']) {
updateData['Signature Link CC'] = signingLinks['Approval'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc');
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
console.log('[EMBEDDED] CC URL:', signingLinks['Oscar Faragher'], '-> Embedded:', embeddedCCUrl);
console.log('[EMBEDDED] CC URL:', signingLinks['Approval'], '-> Embedded:', embeddedCCUrl);
}
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);

View File

@@ -8,18 +8,22 @@ export default defineEventHandler(async (event) => {
await requireAuth(event);
console.log('[Delete Generated EOI] Request received');
console.log('[Delete Generated EOI] Request headers:', getHeaders(event));
console.log('[Delete Generated EOI] Request method:', getMethod(event));
try {
const body = await readBody(event);
const { interestId } = body;
const query = getQuery(event);
console.log('[Delete Generated EOI] Interest ID:', interestId);
console.log('[Delete Generated EOI] Query params:', query);
if (!interestId) {
console.error('[Delete Generated EOI] No interest ID provided');
throw createError({
statusCode: 400,
statusMessage: 'Interest ID is required',
statusMessage: 'Interest ID is required. Please provide a valid interest ID.',
});
}
@@ -77,51 +81,132 @@ export default defineEventHandler(async (event) => {
console.log('[Delete Generated EOI] Deleting document from Documenso');
let documensoDeleteSuccessful = false;
let retryCount = 0;
const maxRetries = 3;
try {
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${documensoApiKey}`,
'Content-Type': 'application/json'
}
});
// Retry logic for temporary failures
while (!documensoDeleteSuccessful && retryCount < maxRetries) {
try {
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${documensoApiKey}`,
'Content-Type': 'application/json'
}
});
if (!deleteResponse.ok) {
const errorText = await deleteResponse.text();
console.error('[Delete Generated EOI] Documenso deletion failed:', errorText);
const responseStatus = deleteResponse.status;
let errorDetails = '';
// If it's a 404, the document is already gone, which is what we want
if (deleteResponse.status === 404) {
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
documensoDeleteSuccessful = true;
} else {
throw new Error(`Failed to delete document from Documenso: ${deleteResponse.statusText}`);
try {
errorDetails = await deleteResponse.text();
} catch {
errorDetails = 'No error details available';
}
} else {
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
documensoDeleteSuccessful = true;
}
} catch (error: any) {
console.error('[Delete Generated EOI] Documenso deletion error:', error);
// Check if it's a network error or 404 - in those cases, proceed with cleanup
if (error.message?.includes('404') || error.status === 404) {
console.log('[Delete Generated EOI] Document not found in Documenso - proceeding with database cleanup');
documensoDeleteSuccessful = true;
} else {
if (!deleteResponse.ok) {
console.error(`[Delete Generated EOI] Documenso deletion failed (attempt ${retryCount + 1}/${maxRetries}):`, {
status: responseStatus,
statusText: deleteResponse.statusText,
details: errorDetails
});
// Handle specific status codes
switch (responseStatus) {
case 404:
// Document already deleted - this is fine
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
documensoDeleteSuccessful = true;
break;
case 403:
// Permission denied - document might be in a protected state
console.warn('[Delete Generated EOI] Permission denied (403) - document may be in a protected state');
throw createError({
statusCode: 403,
statusMessage: 'Cannot delete document - it may be fully signed or in a protected state',
});
case 500:
case 502:
case 503:
case 504:
// Server errors - retry if we haven't exceeded retries
if (retryCount < maxRetries - 1) {
console.log(`[Delete Generated EOI] Server error (${responseStatus}) - retrying in ${(retryCount + 1) * 2} seconds...`);
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 2000)); // Exponential backoff
retryCount++;
continue;
} else {
console.error('[Delete Generated EOI] Max retries exceeded for server error');
// Allow proceeding with cleanup for server errors after retries
if (query.forceCleanup === 'true') {
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding despite Documenso error');
documensoDeleteSuccessful = true;
break;
}
throw new Error(`Documenso server error after ${maxRetries} attempts (${responseStatus}): ${errorDetails}`);
}
default:
// Other errors - don't retry
throw new Error(`Documenso API error (${responseStatus}): ${errorDetails || deleteResponse.statusText}`);
}
} else {
console.log('[Delete Generated EOI] Successfully deleted document from Documenso');
documensoDeleteSuccessful = true;
}
} catch (error: any) {
console.error(`[Delete Generated EOI] Documenso deletion error (attempt ${retryCount + 1}/${maxRetries}):`, error);
// Network errors - retry if we haven't exceeded retries
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') {
if (retryCount < maxRetries - 1) {
console.log(`[Delete Generated EOI] Network error - retrying in ${(retryCount + 1) * 2} seconds...`);
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 2000));
retryCount++;
continue;
}
}
// Check if it's a 404 error wrapped in another error
if (error.message?.includes('404') || error.status === 404 || error.statusCode === 404) {
console.log('[Delete Generated EOI] Document not found in Documenso - proceeding with database cleanup');
documensoDeleteSuccessful = true;
break;
}
// Check if force cleanup is enabled
const query = getQuery(event);
if (query.forceCleanup === 'true') {
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding despite Documenso error:', error.message);
documensoDeleteSuccessful = true;
break;
}
// Don't wrap error messages multiple times
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: `Failed to delete document from Documenso: ${error.message}`,
statusMessage: error.message || 'Failed to communicate with Documenso API',
});
}
}
if (!documensoDeleteSuccessful) {
throw createError({
statusCode: 500,
statusMessage: 'Failed to delete document from Documenso',
});
const query = getQuery(event);
if (query.forceCleanup === 'true') {
console.warn('[Delete Generated EOI] Force cleanup enabled - proceeding with database cleanup despite Documenso failure');
documensoDeleteSuccessful = true;
} else {
throw createError({
statusCode: 500,
statusMessage: 'Failed to delete document from Documenso after multiple attempts. You can add ?forceCleanup=true to force database cleanup.',
});
}
}
// Reset interest fields

View File

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

View File

@@ -3,16 +3,20 @@ import { getExpenseById } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency';
import { createError } from 'h3';
import { formatDate } from '@/utils/dateUtils';
import PDFDocument from 'pdfkit';
import { getMinioClient } from '@/server/utils/minio';
interface PDFOptions {
documentName: string;
subheader?: string;
groupBy: 'none' | 'payer' | 'category' | 'date';
includeReceipts: boolean;
includeReceiptContents: boolean;
includeSummary: boolean;
includeDetails: boolean;
pageFormat: 'A4' | 'Letter' | 'Legal';
includeProcessingFee?: boolean;
targetCurrency?: 'USD' | 'EUR';
}
interface Expense {
@@ -20,7 +24,11 @@ interface Expense {
'Establishment Name': string;
Price: string;
PriceNumber: number;
Currency?: string;
CurrencySymbol?: string;
DisplayPrice: string;
DisplayPriceWithEUR?: string;
PriceEUR?: number;
PriceUSD?: number;
ConversionRate?: number;
Payer: string;
@@ -54,12 +62,13 @@ export default defineEventHandler(async (event) => {
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
try {
// Fetch expense data
// Fetch expense data with target currency processing
const targetCurrency = options.targetCurrency || 'EUR';
const expenses: Expense[] = [];
for (const expenseId of expenseIds) {
const expense = await getExpenseById(expenseId);
if (expense) {
const processedExpense = await processExpenseWithCurrency(expense);
const processedExpense = await processExpenseWithCurrency(expense, targetCurrency);
expenses.push(processedExpense);
}
}
@@ -72,33 +81,28 @@ export default defineEventHandler(async (event) => {
}
// Calculate totals
const totals = calculateTotals(expenses, options.includeProcessingFee);
const totals = calculateTotals(expenses, options.includeProcessingFee, targetCurrency);
console.log('[expenses/generate-pdf] Successfully calculated totals:', totals);
console.log('[expenses/generate-pdf] Options received:', options);
// Generate PDF content
const pdfContent = generatePDFContent(expenses, options, totals);
// Generate PDF using PDFKit
const pdfBuffer = await generatePDFWithPDFKit(expenses, options, totals);
// Return PDF as base64 for download
const pdfBase64 = Buffer.from(pdfContent).toString('base64');
const pdfBase64 = pdfBuffer.toString('base64');
return {
success: true,
data: {
filename: `${options.documentName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`,
filename: `${options.documentName.replace(/[^a-zA-Z0-9\-_\s]/g, '_')}.pdf`,
content: pdfBase64,
mimeType: 'application/pdf',
size: pdfContent.length
size: pdfBuffer.length
}
};
} catch (error: any) {
// If it's our intentional error, re-throw it
if (error.statusCode === 501) {
throw error;
}
console.error('[expenses/generate-pdf] Error generating PDF:', error);
throw createError({
statusCode: 500,
@@ -107,18 +111,33 @@ export default defineEventHandler(async (event) => {
}
});
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean) {
const originalTotal = expenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false, targetCurrency: string = 'EUR') {
// Calculate target currency total
const targetTotal = expenses.reduce((sum, exp) => {
if (targetCurrency.toUpperCase() === 'USD') {
return sum + (exp.PriceUSD || exp.PriceNumber || 0);
} else {
return sum + (exp.PriceEUR || exp.PriceNumber || 0);
}
}, 0);
// Calculate EUR total for compatibility
const eurTotal = expenses.reduce((sum, exp) => sum + (exp.PriceEUR || exp.PriceNumber || 0), 0);
// Calculate USD total for compatibility
const usdTotal = expenses.reduce((sum, exp) => sum + (exp.PriceUSD || exp.PriceNumber || 0), 0);
const processingFee = includeProcessingFee ? originalTotal * 0.05 : 0;
const finalTotal = originalTotal + processingFee;
// Processing fee is calculated on target currency total
const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0;
const finalTotal = targetTotal + processingFee;
return {
originalTotal,
targetTotal,
eurTotal,
usdTotal,
processingFee,
finalTotal,
targetCurrency: targetCurrency.toUpperCase(),
count: expenses.length
};
}
@@ -132,127 +151,315 @@ function getGroupingLabel(groupBy: string): string {
}
}
function generatePDFContent(expenses: Expense[], options: PDFOptions, totals: any): string {
// Generate HTML content that can be converted to PDF
const html = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${options.documentName}</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.header { text-align: center; margin-bottom: 30px; border-bottom: 2px solid #333; padding-bottom: 20px; }
.document-title { font-size: 24px; font-weight: bold; margin-bottom: 10px; }
.subheader { font-size: 16px; color: #666; }
.summary { background-color: #f5f5f5; padding: 15px; margin: 20px 0; border-radius: 5px; }
.expense-table { width: 100%; border-collapse: collapse; margin-top: 20px; }
.expense-table th, .expense-table td { border: 1px solid #ddd; padding: 8px; text-align: left; }
.expense-table th { background-color: #f2f2f2; font-weight: bold; }
.expense-table tr:nth-child(even) { background-color: #f9f9f9; }
.group-header { background-color: #e7f3ff; font-weight: bold; }
.total-row { background-color: #d4edda; font-weight: bold; }
.processing-fee { background-color: #fff3cd; }
.final-total { background-color: #d1ecf1; font-weight: bold; font-size: 1.1em; }
.date-generated { text-align: right; color: #666; font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<div class="header">
<div class="document-title">${options.documentName}</div>
${options.subheader ? `<div class="subheader">${options.subheader}</div>` : ''}
</div>
${options.includeSummary ? `
<div class="summary">
<h3>Summary</h3>
<p><strong>Total Expenses:</strong> ${totals.count}</p>
<p><strong>Subtotal:</strong> €${totals.originalTotal.toFixed(2)}</p>
<p><strong>USD Equivalent:</strong> $${totals.usdTotal.toFixed(2)}</p>
${options.includeProcessingFee ? `<p><strong>Processing Fee (5%):</strong> €${totals.processingFee.toFixed(2)}</p>` : ''}
<p><strong>Final Total:</strong> €${totals.finalTotal.toFixed(2)}</p>
<p><strong>Grouping:</strong> ${getGroupingLabel(options.groupBy)}</p>
</div>
` : ''}
${options.includeDetails ? generateExpenseTable(expenses, options) : ''}
<div class="date-generated">
Generated on: ${new Date().toLocaleString()}
</div>
</body>
</html>`;
return html;
function getPageDimensions(pageFormat: string) {
switch (pageFormat) {
case 'Letter':
return { width: 612, height: 792 }; // 8.5" x 11"
case 'Legal':
return { width: 612, height: 1008 }; // 8.5" x 14"
case 'A4':
default:
return { width: 595, height: 842 }; // A4
}
}
function generateExpenseTable(expenses: Expense[], options: PDFOptions): string {
let tableHTML = `
<table class="expense-table">
<thead>
<tr>
<th>Date</th>
<th>Establishment</th>
<th>Category</th>
<th>Payer</th>
<th>Amount</th>
<th>Payment Method</th>
${options.includeDetails ? '<th>Description</th>' : ''}
</tr>
</thead>
<tbody>
`;
async function generatePDFWithPDFKit(expenses: Expense[], options: PDFOptions, totals: any): Promise<Buffer> {
return new Promise(async (resolve, reject) => {
try {
console.log('[expenses/generate-pdf] Generating PDF with PDFKit...');
const pageDimensions = getPageDimensions(options.pageFormat);
const doc = new PDFDocument({
size: [pageDimensions.width, pageDimensions.height],
margins: { top: 60, bottom: 60, left: 60, right: 60 }
});
const chunks: Buffer[] = [];
doc.on('data', (chunk) => chunks.push(chunk));
doc.on('end', () => {
const pdfBuffer = Buffer.concat(chunks);
console.log('[expenses/generate-pdf] PDF generated successfully, size:', pdfBuffer.length, 'bytes');
resolve(pdfBuffer);
});
doc.on('error', reject);
// Add header
addHeader(doc, options);
// Add summary if requested
if (options.includeSummary) {
addSummary(doc, totals, options);
}
// Add expense details if requested
if (options.includeDetails) {
await addExpenseTable(doc, expenses, options);
}
// Add receipt images if requested
if (options.includeReceipts) {
await addReceiptImages(doc, expenses);
}
// Add footer
addFooter(doc);
doc.end();
} catch (error: any) {
console.error('[expenses/generate-pdf] PDFKit error:', error);
reject(new Error(`PDF generation failed: ${error?.message || 'Unknown error'}`));
}
});
}
if (options.groupBy === 'none') {
// No grouping - just list all expenses
expenses.forEach(expense => {
tableHTML += generateExpenseRow(expense, options);
});
function addHeader(doc: PDFKit.PDFDocument, options: PDFOptions) {
doc.fontSize(24)
.font('Helvetica-Bold')
.text(options.documentName, { align: 'center' });
if (options.subheader) {
doc.fontSize(16)
.font('Helvetica')
.fillColor('#666666')
.text(options.subheader, { align: 'center' });
}
// Add line separator
const y = doc.y + 10;
doc.moveTo(60, y)
.lineTo(doc.page.width - 60, y)
.strokeColor('#333333')
.lineWidth(2)
.stroke();
doc.y = y + 20;
doc.fillColor('#000000'); // Reset color
}
function addSummary(doc: PDFKit.PDFDocument, totals: any, options: PDFOptions) {
doc.fontSize(18)
.font('Helvetica-Bold')
.text('Summary', { continued: false });
doc.y += 10;
// Summary box
const boxY = doc.y;
const boxHeight = options.includeProcessingFee ? 140 : 120;
doc.rect(60, boxY, doc.page.width - 120, boxHeight)
.fillColor('#f5f5f5')
.fill()
.strokeColor('#dddddd')
.stroke();
doc.fillColor('#000000');
// Summary content
doc.y = boxY + 15;
doc.fontSize(12)
.font('Helvetica');
const leftX = 80;
const targetCurrency = totals.targetCurrency || 'EUR';
const targetSymbol = targetCurrency === 'USD' ? '$' : '€';
doc.text(`Total Expenses:`, leftX, doc.y, { continued: true })
.font('Helvetica-Bold')
.text(` ${totals.count}`, { align: 'left' });
doc.font('Helvetica')
.text(`Subtotal (${targetCurrency}):`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(` ${targetSymbol}${totals.targetTotal.toFixed(2)}`, { align: 'left' });
// Show the other currency as reference
if (targetCurrency === 'USD') {
doc.font('Helvetica')
.text(`EUR Equivalent:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(`${totals.eurTotal.toFixed(2)}`, { align: 'left' });
} else {
doc.font('Helvetica')
.text(`USD Equivalent:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(` $${totals.usdTotal.toFixed(2)}`, { align: 'left' });
}
if (options.includeProcessingFee) {
doc.font('Helvetica')
.text(`Processing Fee (5%):`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(` ${targetSymbol}${totals.processingFee.toFixed(2)}`, { align: 'left' });
}
doc.font('Helvetica')
.text(`Final Total:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.fontSize(14)
.text(` ${targetSymbol}${totals.finalTotal.toFixed(2)}`, { align: 'left' });
doc.fontSize(12)
.font('Helvetica')
.text(`Grouping:`, leftX, doc.y + 5, { continued: true })
.font('Helvetica-Bold')
.text(` ${getGroupingLabel(options.groupBy)}`, { align: 'left' });
doc.y = boxY + boxHeight + 20;
}
async function addExpenseTable(doc: PDFKit.PDFDocument, expenses: Expense[], options: PDFOptions) {
doc.fontSize(18)
.font('Helvetica-Bold')
.text('Expense Details', { continued: false });
doc.y += 15;
const tableTop = doc.y;
const rowHeight = 25;
const fontSize = 9;
// Column definitions - adjusted for better layout
const columns = [
{ header: 'Date', width: 65, x: 60 },
{ header: 'Establishment', width: 110, x: 125 },
{ header: 'Category', width: 55, x: 235 },
{ header: 'Payer', width: 50, x: 290 },
{ header: 'Amount', width: 55, x: 340 },
{ header: 'Payment', width: 45, x: 395 }
];
if (options.includeReceiptContents) {
columns.push({ header: 'Description', width: 105, x: 440 });
}
// Draw table header
doc.fontSize(fontSize + 1)
.font('Helvetica-Bold')
.fillColor('#000000');
// Header background
doc.rect(60, tableTop, doc.page.width - 120, rowHeight)
.fillColor('#f2f2f2')
.fill()
.strokeColor('#dddddd')
.stroke();
doc.fillColor('#000000');
columns.forEach(col => {
doc.text(col.header, col.x, tableTop + 8, { width: col.width, align: 'left' });
});
let currentY = tableTop + rowHeight;
// Group expenses if needed
if (options.groupBy === 'none') {
currentY = await drawExpenseRows(doc, expenses, columns, currentY, rowHeight, fontSize, options);
} else {
// Group expenses
const groups = groupExpenses(expenses, options.groupBy);
Object.keys(groups).forEach(groupKey => {
const groupExpenses = groups[groupKey];
const groupTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
for (const [groupKey, groupExpenses] of Object.entries(groups)) {
// Check if we need a new page
if (currentY > doc.page.height - 100) {
doc.addPage();
currentY = 60;
}
// Group header
tableHTML += `
<tr class="group-header">
<td colspan="${options.includeDetails ? '7' : '6'}">${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})</td>
</tr>
`;
// Group header - show EUR total
const groupEurTotal = groupExpenses.reduce((sum, exp) => sum + (exp.PriceEUR || exp.PriceNumber || 0), 0);
doc.fontSize(fontSize + 1)
.font('Helvetica-Bold')
.fillColor('#000000');
doc.rect(60, currentY, doc.page.width - 120, rowHeight)
.fillColor('#e7f3ff')
.fill()
.strokeColor('#dddddd')
.stroke();
doc.fillColor('#000000')
.text(`${groupKey} (${groupExpenses.length} expenses - €${groupEurTotal.toFixed(2)})`,
65, currentY + 8, { width: doc.page.width - 130 });
currentY += rowHeight;
// Group expenses
groupExpenses.forEach(expense => {
tableHTML += generateExpenseRow(expense, options);
});
});
currentY = await drawExpenseRows(doc, groupExpenses, columns, currentY, rowHeight, fontSize, options);
}
}
tableHTML += `
</tbody>
</table>
`;
return tableHTML;
}
function generateExpenseRow(expense: Expense, options: PDFOptions): string {
const date = expense.Time ? formatDate(expense.Time) : 'N/A';
const description = expense.Contents || 'N/A';
async function drawExpenseRows(
doc: PDFKit.PDFDocument,
expenses: Expense[],
columns: any[],
startY: number,
rowHeight: number,
fontSize: number,
options: PDFOptions
): Promise<number> {
let currentY = startY;
return `
<tr>
<td>${date}</td>
<td>${expense['Establishment Name'] || 'N/A'}</td>
<td>${expense.Category || 'N/A'}</td>
<td>${expense.Payer || 'N/A'}</td>
<td>€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}</td>
<td>${expense['Payment Method'] || 'N/A'}</td>
${options.includeDetails ? `<td>${description}</td>` : ''}
</tr>
`;
doc.fontSize(fontSize)
.font('Helvetica');
expenses.forEach((expense, index) => {
// Check if we need a new page
if (currentY > doc.page.height - 100) {
doc.addPage();
currentY = 60;
}
// Alternate row colors
if (index % 2 === 0) {
doc.rect(60, currentY, doc.page.width - 120, rowHeight)
.fillColor('#f9f9f9')
.fill();
}
doc.fillColor('#000000');
// Draw row data - show original amount with EUR conversion
const date = expense.Time ? formatDate(expense.Time) : 'N/A';
const establishment = expense['Establishment Name'] || 'N/A';
const category = expense.Category || 'N/A';
const payer = expense.Payer || 'N/A';
// Display amount with EUR conversion if needed
let amount;
if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) {
const symbol = expense.CurrencySymbol || expense.Currency;
amount = `${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`;
} else {
amount = `${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}`;
}
const payment = expense['Payment Method'] || 'N/A';
const rowData = [date, establishment, category, payer, amount, payment];
if (options.includeReceiptContents) {
const description = expense.Contents || 'N/A';
rowData.push(description.length > 40 ? description.substring(0, 37) + '...' : description);
}
rowData.forEach((data, colIndex) => {
if (colIndex < columns.length) {
doc.text(data, columns[colIndex].x, currentY + 8, {
width: columns[colIndex].width - 5,
align: 'left',
ellipsis: true
});
}
});
currentY += rowHeight;
});
return currentY;
}
function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Expense[]> {
@@ -281,3 +488,450 @@ function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Exp
return groups;
}
async function addReceiptImages(doc: PDFKit.PDFDocument, expenses: Expense[]) {
console.log('[expenses/generate-pdf] Adding receipt images...');
console.log('[expenses/generate-pdf] Total expenses to check:', expenses.length);
// Log receipt data structure for debugging
expenses.forEach((expense, index) => {
console.log(`[expenses/generate-pdf] Expense ${index + 1} (ID: ${expense.Id}):`, {
establishment: expense['Establishment Name'],
hasReceipt: !!expense.Receipt,
receiptType: typeof expense.Receipt,
receiptLength: Array.isArray(expense.Receipt) ? expense.Receipt.length : 'N/A',
receiptData: expense.Receipt
});
});
const expensesWithReceipts = expenses.filter(expense =>
expense.Receipt && Array.isArray(expense.Receipt) && expense.Receipt.length > 0
);
console.log('[expenses/generate-pdf] Expenses with receipts:', expensesWithReceipts.length);
if (expensesWithReceipts.length === 0) {
console.log('[expenses/generate-pdf] No receipts found to include');
return;
}
let totalReceiptImages = 0;
let processedImages = 0;
let currentReceiptNumber = 0; // Track overall receipt number across all expenses
// Count total receipt images for progress tracking
expensesWithReceipts.forEach(expense => {
if (expense.Receipt && Array.isArray(expense.Receipt)) {
totalReceiptImages += expense.Receipt.length;
}
});
console.log('[expenses/generate-pdf] Total receipt images to process:', totalReceiptImages);
for (const expense of expensesWithReceipts) {
try {
console.log('[expenses/generate-pdf] Processing receipts for expense:', expense.Id, expense['Establishment Name']);
// Process receipt images - each gets its own page
if (expense.Receipt && Array.isArray(expense.Receipt)) {
for (const [receiptIndex, receipt] of expense.Receipt.entries()) {
currentReceiptNumber++; // Increment overall receipt counter
if (receipt.url || receipt.signedUrl || receipt.directus_files_id?.filename_download || receipt.filename_download) {
try {
console.log(`[expenses/generate-pdf] Fetching receipt ${currentReceiptNumber}/${totalReceiptImages} (expense ${expense.Id}, receipt ${receiptIndex + 1}/${expense.Receipt.length})`);
const imageBuffer = await fetchReceiptImage(receipt);
if (imageBuffer) {
// Add new page for each receipt image
doc.addPage();
// Add header section for this receipt
const headerHeight = 100;
// Header background
doc.rect(60, 60, doc.page.width - 120, headerHeight)
.fillColor('#f8f9fa')
.fill()
.strokeColor('#dee2e6')
.lineWidth(1)
.stroke();
doc.fillColor('#000000');
// Receipt header content with overall numbering
doc.fontSize(16)
.font('Helvetica-Bold')
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
70, 80, { align: 'left' });
// Show amount with EUR conversion
let amountText;
if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) {
const symbol = expense.CurrencySymbol || expense.Currency;
amountText = `${expense['Establishment Name']} - ${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`;
} else {
amountText = `${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`;
}
doc.fontSize(14)
.font('Helvetica-Bold')
.text(amountText, 70, 105, { align: 'left' });
doc.fontSize(12)
.font('Helvetica')
.text(`Date: ${expense.Time ? formatDate(expense.Time) : 'N/A'}`,
70, 125, { align: 'left' });
doc.fontSize(10)
.fillColor('#666666')
.text(`Payer: ${expense.Payer || 'N/A'} | Category: ${expense.Category || 'N/A'}`,
70, 140, { align: 'left' });
doc.fillColor('#000000');
// Calculate available space for image (full page minus header and margins)
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const margin = 60;
const imageStartY = 60 + headerHeight + 20; // Header + spacing
const maxImageWidth = pageWidth - (margin * 2);
const maxImageHeight = pageHeight - imageStartY - margin;
console.log(`[expenses/generate-pdf] Adding large image - Max size: ${maxImageWidth}x${maxImageHeight}, Buffer size: ${imageBuffer.length} bytes`);
// Add the receipt image with maximum size
try {
doc.image(imageBuffer, margin, imageStartY, {
fit: [maxImageWidth, maxImageHeight],
align: 'center',
valign: 'center'
});
processedImages++;
console.log(`[expenses/generate-pdf] Successfully added receipt image ${processedImages}/${totalReceiptImages}`);
} catch (imageEmbedError: any) {
console.error('[expenses/generate-pdf] Error embedding image in PDF:', imageEmbedError);
// Add error message on the page
doc.fontSize(14)
.fillColor('#dc3545')
.text('Receipt image could not be embedded', margin, imageStartY + 50, {
align: 'center',
width: maxImageWidth
});
doc.fontSize(12)
.fillColor('#6c757d')
.text(`Error: ${imageEmbedError.message || 'Unknown error'}`, margin, imageStartY + 80, {
align: 'center',
width: maxImageWidth
});
doc.fillColor('#000000');
}
} else {
console.warn(`[expenses/generate-pdf] No image buffer received for receipt ${currentReceiptNumber} of expense ${expense.Id}`);
// Add page with error message
doc.addPage();
doc.fontSize(16)
.font('Helvetica-Bold')
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
{ align: 'center' });
// Show amount with EUR conversion
let centerAmountText;
if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) {
const symbol = expense.CurrencySymbol || expense.Currency;
centerAmountText = `${expense['Establishment Name']} - ${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`;
} else {
centerAmountText = `${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`;
}
doc.fontSize(14)
.font('Helvetica')
.text(centerAmountText, { align: 'center' });
doc.y += 50;
doc.fontSize(12)
.fillColor('#dc3545')
.text('Receipt image could not be loaded from storage', { align: 'center' });
doc.fillColor('#000000');
}
} catch (imageError: any) {
console.error(`[expenses/generate-pdf] Error processing receipt ${currentReceiptNumber} for expense ${expense.Id}:`, imageError);
// Add page with error information
doc.addPage();
doc.fontSize(16)
.font('Helvetica-Bold')
.text(`Receipt Image ${currentReceiptNumber} of ${totalReceiptImages}`,
{ align: 'center' });
// Show amount with EUR conversion
let errorAmountText;
if (expense.Currency && expense.Currency.toUpperCase() !== 'EUR' && expense.PriceEUR) {
const symbol = expense.CurrencySymbol || expense.Currency;
errorAmountText = `${expense['Establishment Name']} - ${symbol}${expense.PriceNumber?.toFixed(2)} (€${expense.PriceEUR.toFixed(2)})`;
} else {
errorAmountText = `${expense['Establishment Name']} - €${expense.PriceNumber?.toFixed(2)}`;
}
doc.fontSize(14)
.font('Helvetica')
.text(errorAmountText, { align: 'center' });
doc.y += 50;
doc.fontSize(12)
.fillColor('#dc3545')
.text('Error loading receipt image', { align: 'center' });
doc.fontSize(10)
.fillColor('#6c757d')
.text(`${imageError.message || 'Unknown error'}`, { align: 'center' });
doc.fillColor('#000000');
}
} else {
console.log(`[expenses/generate-pdf] Skipping receipt ${currentReceiptNumber} for expense ${expense.Id} - no valid file path`);
}
}
}
} catch (error) {
console.error('[expenses/generate-pdf] Error processing receipts for expense:', expense.Id, error);
}
}
console.log(`[expenses/generate-pdf] Completed processing ${processedImages}/${totalReceiptImages} receipt images`);
}
async function fetchReceiptImage(receipt: any): Promise<Buffer | null> {
try {
// Determine the file path - try multiple possible sources
let rawPath = null;
// Try different receipt data structures - prioritize signedUrl for S3 URLs
if (receipt.signedUrl) {
rawPath = receipt.signedUrl;
} else if (receipt.url) {
rawPath = receipt.url;
} else if (receipt.directus_files_id?.filename_download) {
rawPath = receipt.directus_files_id.filename_download;
} else if (receipt.filename_download) {
rawPath = receipt.filename_download;
} else if (receipt.id && receipt.filename_disk) {
rawPath = receipt.filename_disk;
} else if (typeof receipt === 'string') {
rawPath = receipt;
}
if (!rawPath) {
console.log('[expenses/generate-pdf] No file path found for receipt:', JSON.stringify(receipt, null, 2));
return null;
}
console.log('[expenses/generate-pdf] Raw path from receipt:', rawPath);
// Check if this is an S3 URL (HTTP/HTTPS)
if (rawPath.startsWith('http://') || rawPath.startsWith('https://')) {
console.log('[expenses/generate-pdf] Detected S3 URL, fetching directly...');
try {
// Use the signed URL directly without modification to preserve AWS signature
console.log('[expenses/generate-pdf] Fetching from S3 URL (preserving signature):', rawPath);
// Fetch image directly from S3 URL with minimal headers to avoid signature issues
const response = await fetch(rawPath, {
method: 'GET',
headers: {
'Accept': 'image/*'
},
// Add timeout to prevent hanging
signal: AbortSignal.timeout(30000) // 30 second timeout
});
if (!response.ok) {
console.error(`[expenses/generate-pdf] Failed to fetch image from S3: ${response.status} ${response.statusText}`);
console.error('[expenses/generate-pdf] Response headers:', Object.fromEntries(response.headers.entries()));
return null;
}
// Convert response to buffer
const arrayBuffer = await response.arrayBuffer();
const imageBuffer = Buffer.from(arrayBuffer);
console.log('[expenses/generate-pdf] Successfully fetched image from S3 URL, Size:', imageBuffer.length);
return imageBuffer;
} catch (fetchError: any) {
console.error('[expenses/generate-pdf] Error fetching from S3 URL:', fetchError.message);
console.error('[expenses/generate-pdf] Error details:', {
name: fetchError.name,
code: fetchError.code,
message: fetchError.message
});
// Don't try multiple attempts for signed URLs as they may expire
return null;
}
}
// If not an S3 URL, try MinIO as fallback
console.log('[expenses/generate-pdf] Not an S3 URL, trying MinIO fallback...');
const client = getMinioClient();
const bucketName = useRuntimeConfig().minio.bucketName;
// Extract MinIO path from the raw path
let minioPath = extractMinioPath(rawPath);
if (!minioPath) {
console.log('[expenses/generate-pdf] Could not extract MinIO path from:', rawPath);
return null;
}
console.log('[expenses/generate-pdf] Extracted MinIO path:', minioPath);
// Try multiple possible locations in MinIO
const possiblePaths = [
minioPath,
`receipts/${minioPath}`,
`expenses/${minioPath}`,
// Try without any folder prefix
minioPath.split('/').pop() || minioPath,
// Try in receipts folder with just filename
`receipts/${minioPath.split('/').pop() || minioPath}`,
// Try in expenses folder with just filename
`expenses/${minioPath.split('/').pop() || minioPath}`
];
// Remove duplicates
const uniquePaths = [...new Set(possiblePaths)];
for (const testPath of uniquePaths) {
try {
console.log('[expenses/generate-pdf] Trying MinIO path:', testPath);
// Check if object exists first
await client.statObject(bucketName, testPath);
// Get the object from MinIO
const dataStream = await client.getObject(bucketName, testPath);
// Convert stream to buffer
const chunks: Buffer[] = [];
const imageBuffer = await new Promise<Buffer>((resolve, reject) => {
dataStream.on('data', (chunk) => chunks.push(chunk));
dataStream.on('end', () => resolve(Buffer.concat(chunks)));
dataStream.on('error', reject);
});
console.log('[expenses/generate-pdf] Successfully fetched image from MinIO path:', testPath, 'Size:', imageBuffer.length);
return imageBuffer;
} catch (pathError) {
console.log('[expenses/generate-pdf] MinIO path not found:', testPath);
continue;
}
}
console.log('[expenses/generate-pdf] Could not find image in any of the attempted MinIO paths:', uniquePaths);
return null;
} catch (error) {
console.error('[expenses/generate-pdf] Error fetching receipt image:', error);
return null;
}
}
/**
* Extract the MinIO path from an S3 URL or return the path as-is
*/
function extractMinioPath(urlOrPath: string): string | null {
try {
// If it's already just a path, return it
if (!urlOrPath.startsWith('http')) {
return urlOrPath;
}
// Parse the URL
const url = new URL(urlOrPath);
// Extract the pathname (removes query parameters)
let pathname = decodeURIComponent(url.pathname);
console.log('[expenses/generate-pdf] URL pathname:', pathname);
// For S3 URLs, we need to extract the part after the bucket name
// Pattern: /database/nc/uploads/path/to/file.jpg
// We want: uploads/path/to/file.jpg
// Remove leading slash
if (pathname.startsWith('/')) {
pathname = pathname.substring(1);
}
// Look for common patterns
if (pathname.includes('/uploads/')) {
// Extract everything from 'uploads/' onwards
const uploadsIndex = pathname.indexOf('uploads/');
const extractedPath = pathname.substring(uploadsIndex);
console.log('[expenses/generate-pdf] Extracted path from uploads pattern:', extractedPath);
return extractedPath;
}
if (pathname.includes('/nc/uploads/')) {
// Extract everything from 'uploads/' onwards
const uploadsIndex = pathname.indexOf('uploads/');
const extractedPath = pathname.substring(uploadsIndex);
console.log('[expenses/generate-pdf] Extracted path from nc/uploads pattern:', extractedPath);
return extractedPath;
}
if (pathname.includes('/database/')) {
// Remove the database prefix
const databaseIndex = pathname.indexOf('database/');
const withoutDatabase = pathname.substring(databaseIndex + 'database/'.length);
console.log('[expenses/generate-pdf] Extracted path after removing database prefix:', withoutDatabase);
return withoutDatabase;
}
// If no specific pattern found, return the pathname as-is
console.log('[expenses/generate-pdf] Using pathname as-is:', pathname);
return pathname;
} catch (error) {
console.error('[expenses/generate-pdf] Error parsing URL:', error);
// If URL parsing fails, try to extract manually
// Remove query parameters manually
const withoutQuery = urlOrPath.split('?')[0];
// Look for uploads pattern
if (withoutQuery.includes('/uploads/')) {
const uploadsIndex = withoutQuery.indexOf('/uploads/');
return withoutQuery.substring(uploadsIndex + 1); // +1 to remove the leading slash
}
return null;
}
}
function addFooter(doc: PDFKit.PDFDocument) {
doc.fontSize(10)
.fillColor('#666666')
.text(`Generated on: ${new Date().toLocaleString()}`,
60, doc.page.height - 40, { align: 'right' });
}

View File

@@ -3,13 +3,55 @@ import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency';
import type { ExpenseFilters } from '@/utils/types';
// Retry operation wrapper for database calls
async function retryOperation<T>(
operation: () => Promise<T>,
maxRetries: number = 3,
baseDelay: number = 1000
): Promise<T> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error: any) {
console.log(`[get-expenses] Attempt ${attempt}/${maxRetries} failed:`, error.message);
// Don't retry on authentication/authorization errors
if (error.statusCode === 401 || error.statusCode === 403) {
throw error;
}
// Don't retry on client errors (4xx except 404)
if (error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 404) {
throw error;
}
// If this is the last attempt, throw the error
if (attempt === maxRetries) {
throw error;
}
// For retryable errors (5xx, network errors, timeouts), wait before retry
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
console.log(`[get-expenses] Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
throw new Error('Retry operation failed unexpectedly');
}
export default defineEventHandler(async (event) => {
console.log('[get-expenses] API called with query:', getQuery(event));
try {
// Check authentication
// Set proper headers
setHeader(event, 'Cache-Control', 'no-cache');
setHeader(event, 'Content-Type', 'application/json');
// Check authentication first
try {
await requireSalesOrAdmin(event);
console.log('[get-expenses] Authentication successful');
} catch (authError: any) {
console.error('[get-expenses] Authentication failed:', authError);
@@ -32,7 +74,7 @@ export default defineEventHandler(async (event) => {
console.log('[get-expenses] No date filters provided, defaulting to current month');
try {
const result = await getCurrentMonthExpenses();
const result = await retryOperation(() => getCurrentMonthExpenses());
// Process expenses with currency conversion
const processedExpenses = await Promise.all(
@@ -53,6 +95,13 @@ export default defineEventHandler(async (event) => {
});
}
if (dbError.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'No expense records found for the current month.'
});
}
throw createError({
statusCode: 500,
statusMessage: 'Unable to fetch expense data. Please try again later.'
@@ -82,7 +131,7 @@ export default defineEventHandler(async (event) => {
console.log('[get-expenses] Fetching expenses with filters:', filters);
try {
const result = await getExpenses(filters);
const result = await retryOperation(() => getExpenses(filters));
// Process expenses with currency conversion
const processedExpenses = await Promise.all(
@@ -122,19 +171,46 @@ export default defineEventHandler(async (event) => {
});
}
if (dbError.statusCode === 404) {
throw createError({
statusCode: 404,
statusMessage: 'No expense records found matching the specified criteria.'
});
}
throw createError({
statusCode: 500,
statusMessage: 'Unable to fetch expense data. Please try again later.'
});
}
} catch (authError: any) {
if (authError.statusCode === 403) {
} catch (error: any) {
console.error('[get-expenses] Top-level error:', error);
// If it's already a proper H3 error, re-throw it
if (error.statusCode) {
throw error;
}
// Handle authentication errors specifically
if (error.message?.includes('authentication') || error.message?.includes('auth')) {
throw createError({
statusCode: 403,
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
statusCode: 401,
statusMessage: 'Authentication required. Please log in again.'
});
}
throw authError;
// Handle database connection errors
if (error.message?.includes('database') || error.message?.includes('connection')) {
throw createError({
statusCode: 503,
statusMessage: 'Database temporarily unavailable. Please try again later.'
});
}
// Generic server error for anything else
throw createError({
statusCode: 500,
statusMessage: 'An unexpected error occurred. Please try again later.'
});
}
});

View File

@@ -1,6 +1,7 @@
import { requireSalesOrAdmin } from '~/server/utils/auth';
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
import { logAuditEvent } from '~/server/utils/audit-logger';
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
export default defineEventHandler(async (event) => {
console.log('[INTERESTS] Find duplicates request');
@@ -19,11 +20,12 @@ export default defineEventHandler(async (event) => {
let url = `${config.url}/api/v2/tables/${interestTableId}/records`;
// Add date filtering if specified
// Add date filtering if specified (include records without Created At)
if (dateRange && dateRange > 0) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - dateRange);
const dateFilter = `(Created At,gte,${cutoffDate.toISOString()})`;
// Include records without Created At OR within date range
const dateFilter = `((Created At,gte,${cutoffDate.toISOString()}),or,(Created At,is,null))`;
url += `?where=${encodeURIComponent(dateFilter)}`;
}
@@ -39,16 +41,26 @@ export default defineEventHandler(async (event) => {
const interests = response.list || [];
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
// Find potential duplicates
const duplicateGroups = findDuplicateInterests(interests, threshold);
// Find duplicate groups using the new centralized utility
const duplicateConfig = createInterestConfig();
const duplicateGroups = findDuplicates(interests, duplicateConfig);
// Convert to the expected format
const formattedGroups = duplicateGroups.map(group => ({
id: group.id,
interests: group.items,
matchReason: group.matchReason,
confidence: group.confidence,
masterCandidate: group.masterCandidate
}));
console.log('[INTERESTS] Found', duplicateGroups.length, 'duplicate groups');
console.log('[INTERESTS] Found', formattedGroups.length, 'duplicate groups');
// Log the audit event
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
changes: {
totalInterests: interests.length,
duplicateGroups: duplicateGroups.length,
duplicateGroups: formattedGroups.length,
threshold,
dateRange
}
@@ -57,9 +69,9 @@ export default defineEventHandler(async (event) => {
return {
success: true,
data: {
duplicateGroups,
duplicateGroups: formattedGroups,
totalInterests: interests.length,
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
threshold,
dateRange
}
@@ -81,245 +93,3 @@ export default defineEventHandler(async (event) => {
};
}
});
/**
* Find duplicate interests based on multiple criteria
*/
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
const duplicateGroups: Array<{
id: string;
interests: any[];
matchReason: string;
confidence: number;
masterCandidate: any;
}> = [];
const processedIds = new Set<number>();
for (let i = 0; i < interests.length; i++) {
const interest1 = interests[i];
if (processedIds.has(interest1.Id)) continue;
const matches = [interest1];
for (let j = i + 1; j < interests.length; j++) {
const interest2 = interests[j];
if (processedIds.has(interest2.Id)) continue;
const similarity = calculateSimilarity(interest1, interest2);
if (similarity.score >= threshold) {
matches.push(interest2);
processedIds.add(interest2.Id);
}
}
if (matches.length > 1) {
// Mark all as processed
matches.forEach(match => processedIds.add(match.Id));
// Determine the best master candidate (most complete record)
const masterCandidate = selectMasterCandidate(matches);
// 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
});
}
}
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 = normalizeEmail(interest1['Email Address']) === normalizeEmail(interest2['Email Address']) ? 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 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

@@ -303,6 +303,80 @@ export const convertToUSD = async (amount: number, fromCurrency: string): Promis
}
};
/**
* Convert amount from one currency to EUR
*/
export const convertToEUR = async (amount: number, fromCurrency: string): Promise<{
eurAmount: number;
rate: number;
conversionDate: string;
} | null> => {
// If already EUR, no conversion needed
if (fromCurrency.toUpperCase() === 'EUR') {
return {
eurAmount: amount,
rate: 1.0,
conversionDate: new Date().toISOString()
};
}
try {
const rateCache = await getExchangeRates();
if (!rateCache) {
console.error('[currency] No exchange rates available for conversion');
return null;
}
const fromCurrencyUpper = fromCurrency.toUpperCase();
// Get USD -> EUR rate
const usdToEurRate = rateCache.rates['EUR'];
if (!usdToEurRate) {
console.error('[currency] EUR rate not available');
return null;
}
// If converting from USD to EUR
if (fromCurrencyUpper === 'USD') {
const eurAmount = amount * usdToEurRate;
console.log(`[currency] Converted ${amount} USD to ${eurAmount.toFixed(2)} EUR (rate: ${usdToEurRate.toFixed(4)})`);
return {
eurAmount: parseFloat(eurAmount.toFixed(2)),
rate: parseFloat(usdToEurRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
}
// For other currencies, convert through USD first
const usdToSourceRate = rateCache.rates[fromCurrencyUpper];
if (!usdToSourceRate) {
console.error(`[currency] Currency ${fromCurrencyUpper} not supported`);
return null;
}
// Calculate: Source -> USD -> EUR
// Source -> USD: amount / usdToSourceRate
// USD -> EUR: (amount / usdToSourceRate) * usdToEurRate
const sourceToEurRate = usdToEurRate / usdToSourceRate;
const eurAmount = amount * sourceToEurRate;
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${eurAmount.toFixed(2)} EUR (rate: ${sourceToEurRate.toFixed(4)})`);
return {
eurAmount: parseFloat(eurAmount.toFixed(2)),
rate: parseFloat(sourceToEurRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
} catch (error) {
console.error('[currency] Error during EUR conversion:', error);
return null;
}
};
/**
* Format price with currency symbol
*/
@@ -403,46 +477,160 @@ export const getCacheStatus = async (): Promise<{
}
};
/**
* Convert amount from any currency to target currency
*/
export const convertToTargetCurrency = async (
amount: number,
fromCurrency: string,
targetCurrency: string
): Promise<{
targetAmount: number;
rate: number;
conversionDate: string;
} | null> => {
// If same currency, no conversion needed
if (fromCurrency.toUpperCase() === targetCurrency.toUpperCase()) {
return {
targetAmount: amount,
rate: 1.0,
conversionDate: new Date().toISOString()
};
}
// Use existing functions for specific conversions
if (targetCurrency.toUpperCase() === 'USD') {
const result = await convertToUSD(amount, fromCurrency);
if (result) {
return {
targetAmount: result.usdAmount,
rate: result.rate,
conversionDate: result.conversionDate
};
}
return null;
}
if (targetCurrency.toUpperCase() === 'EUR') {
const result = await convertToEUR(amount, fromCurrency);
if (result) {
return {
targetAmount: result.eurAmount,
rate: result.rate,
conversionDate: result.conversionDate
};
}
return null;
}
// For other currencies, convert through USD
try {
const rateCache = await getExchangeRates();
if (!rateCache) {
console.error('[currency] No exchange rates available for conversion');
return null;
}
const fromCurrencyUpper = fromCurrency.toUpperCase();
const targetCurrencyUpper = targetCurrency.toUpperCase();
// Get rates
const usdToFromRate = rateCache.rates[fromCurrencyUpper];
const usdToTargetRate = rateCache.rates[targetCurrencyUpper];
if (!usdToFromRate || !usdToTargetRate) {
console.error(`[currency] Currency not supported: ${!usdToFromRate ? fromCurrencyUpper : targetCurrencyUpper}`);
return null;
}
// Calculate: Source -> USD -> Target
const fromToTargetRate = usdToTargetRate / usdToFromRate;
const targetAmount = amount * fromToTargetRate;
console.log(`[currency] Converted ${amount} ${fromCurrencyUpper} to ${targetAmount.toFixed(2)} ${targetCurrencyUpper} (rate: ${fromToTargetRate.toFixed(4)})`);
return {
targetAmount: parseFloat(targetAmount.toFixed(2)),
rate: parseFloat(fromToTargetRate.toFixed(4)),
conversionDate: rateCache.lastUpdated
};
} catch (error) {
console.error('[currency] Error during currency conversion:', error);
return null;
}
};
/**
* Enhanced expense processing with currency conversion
*/
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
const processedExpense = { ...expense };
// Parse price number
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
processedExpense.PriceNumber = priceNumber;
// Get currency symbol
// Get currency code and symbol
const currencyCode = expense.currency || 'USD';
processedExpense.Currency = currencyCode;
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
// Convert to USD if not already USD
if (currencyCode.toUpperCase() !== 'USD') {
const conversion = await convertToUSD(priceNumber, currencyCode);
// Convert to target currency if not already in target
const targetCurrencyUpper = targetCurrency.toUpperCase();
const targetField = `Price${targetCurrencyUpper}`;
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
if (conversion) {
processedExpense.PriceUSD = conversion.usdAmount;
processedExpense[targetField] = conversion.targetAmount;
processedExpense.ConversionRate = conversion.rate;
processedExpense.ConversionDate = conversion.conversionDate;
processedExpense.TargetCurrency = targetCurrencyUpper;
}
} else {
// If already USD, set USD amount to original amount
processedExpense.PriceUSD = priceNumber;
// If already in target currency, set target amount to original amount
processedExpense[targetField] = priceNumber;
processedExpense.ConversionRate = 1.0;
processedExpense.ConversionDate = new Date().toISOString();
processedExpense.TargetCurrency = targetCurrencyUpper;
}
// Also convert to USD and EUR for compatibility
if (currencyCode.toUpperCase() !== 'USD') {
const usdConversion = await convertToUSD(priceNumber, currencyCode);
if (usdConversion) {
processedExpense.PriceUSD = usdConversion.usdAmount;
}
} else {
processedExpense.PriceUSD = priceNumber;
}
if (currencyCode.toUpperCase() !== 'EUR') {
const eurConversion = await convertToEUR(priceNumber, currencyCode);
if (eurConversion) {
processedExpense.PriceEUR = eurConversion.eurAmount;
}
} else {
processedExpense.PriceEUR = priceNumber;
}
// Create display prices
processedExpense.DisplayPrice = createDisplayPrice(
priceNumber,
currencyCode,
processedExpense.PriceUSD
);
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
processedExpense.PriceUSD || priceNumber,
'USD'
// Create display price with target currency conversion
const targetAmount = processedExpense[targetField];
if (currencyCode.toUpperCase() !== targetCurrencyUpper && targetAmount) {
const targetSymbol = getCurrencySymbol(targetCurrency);
processedExpense.DisplayPriceWithTarget = `${formatPriceWithCurrency(priceNumber, currencyCode)} (${targetSymbol}${targetAmount.toFixed(2)})`;
} else {
processedExpense.DisplayPriceWithTarget = formatPriceWithCurrency(priceNumber, currencyCode);
}
processedExpense.DisplayPriceTarget = formatPriceWithCurrency(
targetAmount || priceNumber,
targetCurrency
);
return processedExpense;

View File

@@ -0,0 +1,417 @@
import { normalizePersonName } from './nocodb';
/**
* Configuration for duplicate detection
*/
export interface DuplicateDetectionConfig<T> {
type: 'expense' | 'interest';
// Field extractors
getKey: (item: T) => string; // Primary grouping key for blocking
getId: (item: T) => number; // Unique identifier
// Duplicate detection rules
rules: DuplicateRule<T>[];
// Performance settings
maxGroupSize?: number; // Skip groups larger than this
maxComparisons?: number; // Limit total comparisons
}
/**
* A rule for detecting duplicates
*/
export interface DuplicateRule<T> {
name: string;
weight: number;
check: (item1: T, item2: T) => boolean;
}
/**
* Result of duplicate detection
*/
export interface DuplicateGroup<T> {
id: string;
items: T[];
matchReason: string;
confidence: number;
masterCandidate: T;
}
/**
* Main function to find duplicates using an efficient blocking strategy
*/
export function findDuplicates<T>(
items: T[],
config: DuplicateDetectionConfig<T>
): DuplicateGroup<T>[] {
console.log(`[DUPLICATES] Starting detection for ${items.length} ${config.type}s`);
if (items.length === 0) return [];
// Phase 1: Group items by blocking key for efficient comparison
const blocks = new Map<string, T[]>();
items.forEach(item => {
const key = config.getKey(item);
if (!blocks.has(key)) {
blocks.set(key, []);
}
blocks.get(key)!.push(item);
});
console.log(`[DUPLICATES] Created ${blocks.size} blocks from ${items.length} items`);
// Phase 2: Find duplicates within each block
const duplicateGroups: DuplicateGroup<T>[] = [];
const processedIds = new Set<number>();
let totalComparisons = 0;
for (const [blockKey, blockItems] of blocks) {
// Skip large blocks that would be too expensive to process
if (config.maxGroupSize && blockItems.length > config.maxGroupSize) {
console.log(`[DUPLICATES] Skipping large block "${blockKey}" with ${blockItems.length} items`);
continue;
}
// Skip blocks with only one item
if (blockItems.length < 2) continue;
console.log(`[DUPLICATES] Processing block "${blockKey}" with ${blockItems.length} items`);
// Find duplicates within this block
for (let i = 0; i < blockItems.length; i++) {
const item1 = blockItems[i];
if (processedIds.has(config.getId(item1))) continue;
const group = [item1];
const matchedRules = new Set<string>();
for (let j = i + 1; j < blockItems.length; j++) {
const item2 = blockItems[j];
if (processedIds.has(config.getId(item2))) continue;
totalComparisons++;
// Check if items match according to any rule
const matchingRule = config.rules.find(rule => rule.check(item1, item2));
if (matchingRule) {
console.log(`[DUPLICATES] Match found: ${config.getId(item1)} vs ${config.getId(item2)} (rule: ${matchingRule.name})`);
group.push(item2);
matchedRules.add(matchingRule.name);
processedIds.add(config.getId(item2));
}
// Stop if we've hit the comparison limit
if (config.maxComparisons && totalComparisons >= config.maxComparisons) {
console.log(`[DUPLICATES] Hit comparison limit of ${config.maxComparisons}`);
break;
}
}
// If we found duplicates, create a group
if (group.length > 1) {
processedIds.add(config.getId(item1));
const masterCandidate = selectMasterCandidate(group, config.type);
const confidence = calculateGroupConfidence(group, config.rules);
duplicateGroups.push({
id: `group_${duplicateGroups.length + 1}`,
items: group,
matchReason: Array.from(matchedRules).join(', '),
confidence,
masterCandidate
});
}
if (config.maxComparisons && totalComparisons >= config.maxComparisons) {
break;
}
}
if (config.maxComparisons && totalComparisons >= config.maxComparisons) {
break;
}
}
console.log(`[DUPLICATES] Completed ${totalComparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
return duplicateGroups;
}
/**
* Select the best master candidate from a group
*/
function selectMasterCandidate<T>(items: T[], type: 'expense' | 'interest'): T {
return items.reduce((best, current) => {
const bestScore = calculateCompletenessScore(best, type);
const currentScore = calculateCompletenessScore(current, type);
return currentScore > bestScore ? current : best;
});
}
/**
* Calculate completeness score for prioritizing records
*/
function calculateCompletenessScore(item: any, type: 'expense' | 'interest'): number {
let score = 0;
let totalFields = 0;
if (type === 'expense') {
const fields = ['Establishment Name', 'Price', 'Payer', 'Category', 'Contents', 'Time'];
fields.forEach(field => {
totalFields++;
if (item[field] && item[field].toString().trim().length > 0) {
score++;
}
});
// Bonus for detailed contents
if (item.Contents && item.Contents.length > 10) {
score += 0.5;
}
} else if (type === 'interest') {
const fields = ['Full Name', 'Email Address', 'Phone Number', 'Address', 'Extra Comments', 'Berth Size Desired'];
fields.forEach(field => {
totalFields++;
if (item[field] && item[field].toString().trim().length > 0) {
score++;
}
});
}
// Bonus for recent creation
if (item['Created At'] || item.CreatedAt) {
const createdField = item['Created At'] || item.CreatedAt;
const created = new Date(createdField);
const now = new Date();
const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
if (daysOld < 30) score += 0.3;
else if (daysOld < 90) score += 0.15;
}
return totalFields > 0 ? score / totalFields : 0;
}
/**
* Calculate confidence score for a duplicate group
*/
function calculateGroupConfidence<T>(items: T[], rules: DuplicateRule<T>[]): number {
if (items.length < 2) return 0;
let totalConfidence = 0;
let comparisons = 0;
for (let i = 0; i < items.length; i++) {
for (let j = i + 1; j < items.length; j++) {
const matchingRule = rules.find(rule => rule.check(items[i], items[j]));
if (matchingRule) {
totalConfidence += matchingRule.weight;
comparisons++;
}
}
}
return comparisons > 0 ? totalConfidence / comparisons : 0;
}
/**
* Normalize email for comparison
*/
export function normalizeEmail(email: string): string {
return email.toLowerCase().trim();
}
/**
* Normalize phone number for comparison
*/
export function normalizePhone(phone: string): string {
return phone.replace(/\D/g, ''); // Remove all non-digits
}
/**
* Calculate string similarity using Levenshtein distance
*/
export function calculateStringSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase().trim();
const s2 = str2.toLowerCase().trim();
if (s1 === s2) return 1.0;
const distance = levenshteinDistance(s1, s2);
const maxLength = Math.max(s1.length, s2.length);
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
}
/**
* Calculate Levenshtein distance between two strings
*/
function levenshteinDistance(str1: string, str2: string): number {
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
for (let i = 0; i <= str1.length; i += 1) {
matrix[0][i] = i;
}
for (let j = 0; j <= str2.length; j += 1) {
matrix[j][0] = j;
}
for (let j = 1; j <= str2.length; j += 1) {
for (let i = 1; i <= str1.length; i += 1) {
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
matrix[j][i] = Math.min(
matrix[j][i - 1] + 1, // deletion
matrix[j - 1][i] + 1, // insertion
matrix[j - 1][i - 1] + indicator // substitution
);
}
}
return matrix[str2.length][str1.length];
}
/**
* Create configuration for expense duplicate detection
*/
export function createExpenseConfig(): DuplicateDetectionConfig<any> {
return {
type: 'expense',
// Group by normalized payer name for blocking
getKey: (expense) => {
const payer = expense.Payer ? normalizePersonName(expense.Payer) : 'unknown';
const date = expense.Time ? expense.Time.split('T')[0] : 'nodate';
return `${payer}_${date}`;
},
getId: (expense) => expense.Id,
rules: [
{
name: 'Exact match',
weight: 1.0,
check: (exp1, exp2) => {
return exp1['Establishment Name'] === exp2['Establishment Name'] &&
exp1.Price === exp2.Price &&
exp1.Time === exp2.Time;
}
},
{
name: 'Same day, same details',
weight: 0.95,
check: (exp1, exp2) => {
const date1 = exp1.Time?.split('T')[0];
const date2 = exp2.Time?.split('T')[0];
return normalizePersonName(exp1.Payer || '') === normalizePersonName(exp2.Payer || '') &&
exp1['Establishment Name'] === exp2['Establishment Name'] &&
exp1.Price === exp2.Price &&
date1 === date2;
}
},
{
name: 'Close time proximity',
weight: 0.9,
check: (exp1, exp2) => {
if (!exp1.Time || !exp2.Time) return false;
const time1 = new Date(exp1.Time).getTime();
const time2 = new Date(exp2.Time).getTime();
const timeDiff = Math.abs(time1 - time2);
return timeDiff < 5 * 60 * 1000 && // 5 minutes
exp1['Establishment Name'] === exp2['Establishment Name'] &&
exp1.Price === exp2.Price;
}
}
],
maxGroupSize: 50,
maxComparisons: 10000
};
}
/**
* Create configuration for interest duplicate detection
*/
export function createInterestConfig(): DuplicateDetectionConfig<any> {
return {
type: 'interest',
// Group by normalized name prefix for blocking to catch name-based duplicates
getKey: (interest) => {
// Priority 1: Use normalized name prefix (first 3 chars) to catch name duplicates
if (interest['Full Name']) {
const name = interest['Full Name'].toLowerCase().trim();
const prefix = name.substring(0, 3);
return `name_${prefix}`;
}
// Priority 2: Use email domain for email-based grouping
if (interest['Email Address']) {
const email = normalizeEmail(interest['Email Address']);
const domain = email.split('@')[1] || 'unknown';
return `email_${domain}`;
}
// Priority 3: Use phone prefix
if (interest['Phone Number']) {
const phone = normalizePhone(interest['Phone Number']);
const prefix = phone.length >= 4 ? phone.substring(0, 4) : phone;
return `phone_${prefix}`;
}
return 'unknown';
},
getId: (interest) => interest.Id,
rules: [
{
name: 'Same email',
weight: 1.0,
check: (int1, int2) => {
return int1['Email Address'] && int2['Email Address'] &&
normalizeEmail(int1['Email Address']) === normalizeEmail(int2['Email Address']);
}
},
{
name: 'Same phone',
weight: 1.0,
check: (int1, int2) => {
const phone1 = normalizePhone(int1['Phone Number'] || '');
const phone2 = normalizePhone(int2['Phone Number'] || '');
return phone1 && phone2 && phone1.length >= 8 && phone1 === phone2;
}
},
{
name: 'Similar name and address',
weight: 0.8,
check: (int1, int2) => {
if (!int1['Full Name'] || !int2['Full Name']) return false;
const nameSimilarity = calculateStringSimilarity(int1['Full Name'], int2['Full Name']);
if (nameSimilarity > 0.9) {
// If names are very similar, check address too
if (int1.Address && int2.Address) {
const addressSimilarity = calculateStringSimilarity(int1.Address, int2.Address);
return addressSimilarity > 0.8;
}
return true; // Similar names, no address to compare
}
return false;
}
}
],
maxGroupSize: 50,
maxComparisons: 10000
};
}

View File

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

View File

@@ -0,0 +1,272 @@
interface SessionCheckOptions {
nuxtApp?: any
cacheKey?: string
cacheExpiry?: number
fetchFn?: () => Promise<any>
bypassCache?: boolean
}
interface SessionResult {
user: any
authenticated: boolean
groups: string[]
reason?: string
fromCache?: boolean
timestamp?: number
}
interface SessionCache {
result: SessionResult
timestamp: number
expiresAt: number
}
/**
* Centralized session management with request deduplication and caching
*/
class SessionManager {
private static instance: SessionManager
private sessionCheckPromise: Promise<SessionResult> | null = null
private sessionCheckLock = false
private lastCheckTime = 0
private readonly minCheckInterval = 1000 // 1 second minimum between checks
private sessionCache: Map<string, SessionCache> = new Map()
private readonly defaultCacheExpiry = 3 * 60 * 1000 // 3 minutes
private readonly gracePeriod = 5 * 60 * 1000 // 5 minutes grace period
static getInstance(): SessionManager {
if (!SessionManager.instance) {
SessionManager.instance = new SessionManager()
}
return SessionManager.instance
}
/**
* Check session with request deduplication and caching
*/
async checkSession(options: SessionCheckOptions = {}): Promise<SessionResult> {
const requestId = Math.random().toString(36).substring(7)
console.log(`[SESSION_MANAGER:${requestId}] Session check requested`)
// Use default cache key if not provided
const cacheKey = options.cacheKey || 'default'
const cacheExpiry = options.cacheExpiry || this.defaultCacheExpiry
// Check cache first (unless bypassing)
if (!options.bypassCache) {
const cached = this.getCachedSession(cacheKey)
if (cached) {
console.log(`[SESSION_MANAGER:${requestId}] Using cached session (age: ${Math.round((Date.now() - cached.timestamp) / 1000)}s)`)
return cached.result
}
}
// Implement request deduplication
if (this.sessionCheckPromise) {
console.log(`[SESSION_MANAGER:${requestId}] Using in-flight session check`)
return this.sessionCheckPromise
}
// Prevent rapid successive checks
const now = Date.now()
if (now - this.lastCheckTime < this.minCheckInterval) {
console.log(`[SESSION_MANAGER:${requestId}] Rate limiting - using last cached result`)
const cached = this.getCachedSession(cacheKey)
if (cached) {
return cached.result
}
}
this.lastCheckTime = now
this.sessionCheckPromise = this.performSessionCheck(options, requestId, cacheKey, cacheExpiry)
try {
const result = await this.sessionCheckPromise
console.log(`[SESSION_MANAGER:${requestId}] Session check completed:`, {
authenticated: result.authenticated,
reason: result.reason,
fromCache: result.fromCache
})
return result
} finally {
this.sessionCheckPromise = null
}
}
/**
* Get cached session if valid
*/
private getCachedSession(cacheKey: string): SessionCache | null {
const cached = this.sessionCache.get(cacheKey)
if (!cached) return null
const now = Date.now()
// Check if cache is still valid
if (now < cached.expiresAt) {
return cached
}
// Check if we're within grace period for network issues
if (now - cached.timestamp < this.gracePeriod) {
console.log(`[SESSION_MANAGER] Cache expired but within grace period`)
return cached
}
// Remove expired cache
this.sessionCache.delete(cacheKey)
return null
}
/**
* Perform actual session check
*/
private async performSessionCheck(
options: SessionCheckOptions,
requestId: string,
cacheKey: string,
cacheExpiry: number
): Promise<SessionResult> {
const startTime = Date.now()
try {
let result: SessionResult
if (options.fetchFn) {
console.log(`[SESSION_MANAGER:${requestId}] Using custom fetch function`)
result = await options.fetchFn()
} else {
console.log(`[SESSION_MANAGER:${requestId}] Using default session check`)
result = await this.defaultSessionCheck()
}
// Add metadata
result.timestamp = Date.now()
result.fromCache = false
// Cache the result
this.cacheSessionResult(cacheKey, result, cacheExpiry)
const duration = Date.now() - startTime
console.log(`[SESSION_MANAGER:${requestId}] Session check completed in ${duration}ms`)
return result
} catch (error: any) {
console.error(`[SESSION_MANAGER:${requestId}] Session check failed:`, error)
// Try to return cached result during network errors
const cached = this.getCachedSession(cacheKey)
if (cached && this.isNetworkError(error)) {
console.log(`[SESSION_MANAGER:${requestId}] Using cached result due to network error`)
return {
...cached.result,
reason: 'NETWORK_ERROR_CACHED'
}
}
// Return failed result
return {
user: null,
authenticated: false,
groups: [],
reason: error.message || 'SESSION_CHECK_FAILED',
timestamp: Date.now()
}
}
}
/**
* Default session check implementation
*/
private async defaultSessionCheck(): Promise<SessionResult> {
// This would normally make a call to /api/auth/session
// For now, return a placeholder - this will be replaced by actual API call
throw new Error('Default session check not implemented - use fetchFn option')
}
/**
* Cache session result
*/
private cacheSessionResult(cacheKey: string, result: SessionResult, cacheExpiry: number): void {
const jitter = Math.floor(Math.random() * 10000) // 0-10 seconds jitter
const expiresAt = Date.now() + cacheExpiry + jitter
this.sessionCache.set(cacheKey, {
result,
timestamp: Date.now(),
expiresAt
})
console.log(`[SESSION_MANAGER] Cached session result for ${cacheExpiry + jitter}ms`)
}
/**
* Check if error is a network error
*/
private isNetworkError(error: any): boolean {
return error.code === 'ECONNREFUSED' ||
error.code === 'ETIMEDOUT' ||
error.name === 'AbortError' ||
error.code === 'ENOTFOUND' ||
(error.status >= 500 && error.status < 600)
}
/**
* Validate session (used by auth refresh plugin)
*/
async validateSession(): Promise<SessionResult> {
return this.checkSession({
cacheKey: 'validation',
bypassCache: true, // Always fresh check for validation
fetchFn: async () => {
// This will be implemented to call the session API
const response = await fetch('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
if (!response.ok) {
throw new Error(`Session validation failed: ${response.status}`)
}
return response.json()
}
})
}
/**
* Clear all cached sessions
*/
clearCache(): void {
this.sessionCache.clear()
console.log('[SESSION_MANAGER] Session cache cleared')
}
/**
* Get cache statistics
*/
getCacheStats(): { entries: number; oldestEntry: number | null; newestEntry: number | null } {
const entries = this.sessionCache.size
let oldestEntry: number | null = null
let newestEntry: number | null = null
for (const cache of this.sessionCache.values()) {
if (oldestEntry === null || cache.timestamp < oldestEntry) {
oldestEntry = cache.timestamp
}
if (newestEntry === null || cache.timestamp > newestEntry) {
newestEntry = cache.timestamp
}
}
return { entries, oldestEntry, newestEntry }
}
}
// Export singleton instance
export const sessionManager = SessionManager.getInstance()
// Export class for testing
export { SessionManager }

View File

@@ -0,0 +1,268 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { SessionManager } from '~/server/utils/session-manager'
describe('SessionManager', () => {
let sessionManager: SessionManager
let mockFetch: any
beforeEach(() => {
sessionManager = SessionManager.getInstance()
sessionManager.clearCache()
mockFetch = vi.fn()
vi.clearAllMocks()
})
afterEach(() => {
sessionManager.clearCache()
})
describe('Request Deduplication', () => {
it('should deduplicate concurrent requests', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// Make multiple concurrent requests
const promises = [
sessionManager.checkSession({ fetchFn: mockFetch }),
sessionManager.checkSession({ fetchFn: mockFetch }),
sessionManager.checkSession({ fetchFn: mockFetch })
]
const results = await Promise.all(promises)
// Should only call fetch once
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(results.every(r => r.authenticated)).toBe(true)
expect(results.every(r => r.user.id === '123')).toBe(true)
})
it('should handle failed requests and not cache errors', async () => {
const error = new Error('Network error')
mockFetch.mockRejectedValue(error)
const result = await sessionManager.checkSession({ fetchFn: mockFetch })
expect(result.authenticated).toBe(false)
expect(result.reason).toBe('Network error')
expect(mockFetch).toHaveBeenCalledTimes(1)
})
it('should rate limit rapid successive requests', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// First request
await sessionManager.checkSession({ fetchFn: mockFetch })
// Immediate second request should use cache
const secondResult = await sessionManager.checkSession({ fetchFn: mockFetch })
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(secondResult.authenticated).toBe(true)
})
})
describe('Caching', () => {
it('should cache successful responses', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// First request
const firstResult = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'test-cache'
})
// Second request should use cache
const secondResult = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'test-cache'
})
expect(mockFetch).toHaveBeenCalledTimes(1)
expect(firstResult.authenticated).toBe(true)
expect(secondResult.authenticated).toBe(true)
expect(secondResult.fromCache).toBe(true)
})
it('should respect cache expiry', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// First request with very short cache expiry
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'short-cache',
cacheExpiry: 100 // 100ms
})
// Wait for cache to expire
await new Promise(resolve => setTimeout(resolve, 150))
// Second request should make new fetch
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'short-cache',
cacheExpiry: 100
})
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('should use grace period for network errors', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValueOnce(mockResponse)
// First successful request
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'grace-test'
})
// Mock network error
const networkError = new Error('Network error')
networkError.code = 'ECONNREFUSED'
mockFetch.mockRejectedValue(networkError)
// Second request should use cached result due to network error
const result = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'grace-test'
})
expect(result.authenticated).toBe(true)
expect(result.reason).toBe('NETWORK_ERROR_CACHED')
})
})
describe('Session Validation', () => {
it('should validate session with fresh check', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
// Mock the fetch API
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse)
})
const result = await sessionManager.validateSession()
expect(result.authenticated).toBe(true)
expect(global.fetch).toHaveBeenCalledWith('/api/auth/session', {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
})
})
it('should handle validation failure', async () => {
// Mock failed fetch
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401
})
const result = await sessionManager.validateSession()
expect(result.authenticated).toBe(false)
expect(result.reason).toBe('Session validation failed: 401')
})
})
describe('Cache Management', () => {
it('should clear cache', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
// Cache some data
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'clear-test'
})
// Clear cache
sessionManager.clearCache()
// Next request should make fresh fetch
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'clear-test'
})
expect(mockFetch).toHaveBeenCalledTimes(2)
})
it('should provide cache statistics', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValue(mockResponse)
const initialStats = sessionManager.getCacheStats()
expect(initialStats.entries).toBe(0)
// Add some cache entries
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'stats-test-1'
})
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'stats-test-2'
})
const finalStats = sessionManager.getCacheStats()
expect(finalStats.entries).toBe(2)
expect(finalStats.oldestEntry).toBeDefined()
expect(finalStats.newestEntry).toBeDefined()
})
})
describe('Error Handling', () => {
it('should identify network errors correctly', async () => {
const networkErrors = [
{ code: 'ECONNREFUSED' },
{ code: 'ETIMEDOUT' },
{ name: 'AbortError' },
{ code: 'ENOTFOUND' },
{ status: 503 }
]
for (const error of networkErrors) {
mockFetch.mockRejectedValue(error)
const result = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: `network-error-${error.code || error.name || error.status}`
})
expect(result.authenticated).toBe(false)
expect(result.reason).toBe(error.message || 'SESSION_CHECK_FAILED')
}
})
it('should handle non-network errors without grace period', async () => {
const mockResponse = { authenticated: true, user: { id: '123' }, groups: [] }
mockFetch.mockResolvedValueOnce(mockResponse)
// First successful request
await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'non-network-error'
})
// Mock non-network error
const authError = new Error('Auth error')
authError.status = 401
mockFetch.mockRejectedValue(authError)
// Second request should not use cached result for auth errors
const result = await sessionManager.checkSession({
fetchFn: mockFetch,
cacheKey: 'non-network-error'
})
expect(result.authenticated).toBe(false)
expect(result.reason).toBe('Auth error')
})
})
})