Compare commits
26 Commits
4740040202
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bed42c7329 | |||
| 0762306bf3 | |||
| 080cb60d71 | |||
| b8a6a52417 | |||
| 9f7aa99320 | |||
| 1a24faa9db | |||
| 9b045c7b97 | |||
| 7244349fe7 | |||
| 61235b163d | |||
| d71e2d348c | |||
| eb1d853327 | |||
| 7ee2cb3368 | |||
| c6f81a6686 | |||
| bf2361050f | |||
| 242e33f7b9 | |||
| 6ebe96bbf4 | |||
| 3ba8542e4f | |||
| 2928d9a7ed | |||
| 6e99f4f783 | |||
| a00b3918be | |||
| 06500a614d | |||
| 9d49245efa | |||
| 893927d4b1 | |||
| b6d71faf5f | |||
| 3f90db0392 | |||
| a83895bef3 |
@@ -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
1
.gitignore
vendored
@@ -13,3 +13,4 @@ logs
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
nul
|
||||
|
||||
4
app.vue
4
app.vue
@@ -1,5 +1,7 @@
|
||||
<template>
|
||||
<NuxtPwaManifest />
|
||||
<NuxtPage />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
<GlobalToast />
|
||||
</template>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
{{ expense.DisplayPrice || expense.Price }}
|
||||
</div>
|
||||
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
|
||||
<span class="text-caption text-grey-darken-1">
|
||||
<span class="text-caption text-grey-darken-3">
|
||||
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -64,12 +64,12 @@
|
||||
|
||||
<!-- Multiple receipts indicator -->
|
||||
<v-chip
|
||||
v-if="expense.Receipt.length > 1"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="receipt-count-chip"
|
||||
variant="flat"
|
||||
:color="getCategoryColor(expense.Category)"
|
||||
class="text-caption text-grey-darken-3"
|
||||
>
|
||||
+{{ expense.Receipt.length - 1 }}
|
||||
{{ expense.Category || 'Other' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
<span class="text-caption">Delete</span>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="6">
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
||||
variant="flat"
|
||||
@@ -848,7 +848,7 @@ const handleFormSubmit = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const saveInterest = async (isAutoSave = false) => {
|
||||
const saveInterest = async (isAutoSave = false, closeAfterSave = false) => {
|
||||
if (interest.value) {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
@@ -871,7 +871,11 @@ const saveInterest = async (isAutoSave = false) => {
|
||||
if (!isAutoSave) {
|
||||
toast.success("Interest saved successfully!");
|
||||
emit("save", interest.value);
|
||||
closeModal();
|
||||
|
||||
// Only close if explicitly requested
|
||||
if (closeAfterSave) {
|
||||
closeModal();
|
||||
}
|
||||
} else {
|
||||
// For auto-save, just emit save to refresh parent
|
||||
emit("save", interest.value);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
182
docs/404-and-session-fixes.md
Normal file
182
docs/404-and-session-fixes.md
Normal 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.
|
||||
310
docs/authentication-session-timeout-fix-deployment.md
Normal file
310
docs/authentication-session-timeout-fix-deployment.md
Normal 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
|
||||
321
layouts/dashboard-unified.vue
Normal file
321
layouts/dashboard-unified.vue
Normal 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>
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
3535
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nuxt/ui": "^3.2.0",
|
||||
"@pdfme/common": "^5.4.0",
|
||||
"@pdfme/generator": "^5.4.0",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,293 +1,13 @@
|
||||
<template>
|
||||
<v-app full-height>
|
||||
<v-navigation-drawer
|
||||
v-model="drawer"
|
||||
:location="mdAndDown ? 'bottom' : undefined"
|
||||
>
|
||||
<v-img v-if="!mdAndDown" src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="110" class="my-6" contain />
|
||||
|
||||
<v-list color="primary" lines="two">
|
||||
<v-list-item
|
||||
v-for="(item, index) in safeMenu"
|
||||
:key="index"
|
||||
:to="item.to"
|
||||
:title="item.title"
|
||||
:prepend-icon="item.icon"
|
||||
/>
|
||||
</v-list>
|
||||
|
||||
<template #append>
|
||||
<v-list lines="two">
|
||||
<v-list-item
|
||||
v-if="user"
|
||||
:title="user.name"
|
||||
:subtitle="user.email"
|
||||
prepend-icon="mdi-account"
|
||||
>
|
||||
<template #append>
|
||||
<v-chip v-if="user.tier && user.tier !== 'basic'" size="small" color="primary">
|
||||
{{ user.tier }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
@click="logOut"
|
||||
title="Log out"
|
||||
prepend-icon="mdi-logout"
|
||||
base-color="error"
|
||||
/>
|
||||
</v-list>
|
||||
</template>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<v-app-bar v-if="mdAndDown" elevation="2">
|
||||
<template #prepend>
|
||||
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer" />
|
||||
</template>
|
||||
|
||||
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="50" />
|
||||
|
||||
<template #append>
|
||||
<v-btn
|
||||
@click="logOut"
|
||||
class="mr-3"
|
||||
variant="text"
|
||||
color="error"
|
||||
icon="mdi-logout"
|
||||
/>
|
||||
</template>
|
||||
</v-app-bar>
|
||||
|
||||
<v-main>
|
||||
<router-view />
|
||||
</v-main>
|
||||
</v-app>
|
||||
<div>
|
||||
<!-- This page now acts as a parent route for dashboard pages -->
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: ["authentication"],
|
||||
layout: false,
|
||||
layout: "dashboard-unified",
|
||||
});
|
||||
|
||||
const { mdAndDown } = useDisplay();
|
||||
const { user, logout, authSource } = useUnifiedAuth();
|
||||
const { isAdmin, getUserGroups, getCurrentUser } = useAuthorization();
|
||||
const tags = usePortalTags();
|
||||
|
||||
const drawer = ref(false);
|
||||
|
||||
// Debug auth state
|
||||
onMounted(() => {
|
||||
nextTick(() => {
|
||||
console.log('[Dashboard] Auth state on mount:', {
|
||||
isAdmin: isAdmin(),
|
||||
userGroups: getUserGroups(),
|
||||
currentUser: getCurrentUser()
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const interestMenu = computed(() => {
|
||||
const userIsAdmin = isAdmin();
|
||||
const userGroups = getUserGroups();
|
||||
|
||||
console.log('[Dashboard] Computing interest menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
|
||||
|
||||
// Check if user has sales or admin privileges
|
||||
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
|
||||
|
||||
const baseMenu = [
|
||||
//{
|
||||
// to: "/dashboard/interest-eoi-queue",
|
||||
// icon: "mdi-tray-full",
|
||||
// title: "EOI Queue",
|
||||
//},
|
||||
{
|
||||
to: "/dashboard/interest-analytics",
|
||||
icon: "mdi-view-dashboard",
|
||||
title: "Analytics",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/interest-berth-list",
|
||||
icon: "mdi-table",
|
||||
title: "Berth List",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/interest-berth-status",
|
||||
icon: "mdi-sail-boat",
|
||||
title: "Berth Status",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/interest-list",
|
||||
icon: "mdi-view-list",
|
||||
title: "Interest List",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/interest-status",
|
||||
icon: "mdi-account-check",
|
||||
title: "Interest Status",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/file-browser",
|
||||
icon: "mdi-folder",
|
||||
title: "File Browser",
|
||||
},
|
||||
];
|
||||
|
||||
// Only show expenses to sales and admin users
|
||||
if (hasSalesAccess) {
|
||||
console.log('[Dashboard] Adding expenses to menu (user has sales/admin access)');
|
||||
baseMenu.push({
|
||||
to: "/dashboard/expenses",
|
||||
icon: "mdi-receipt",
|
||||
title: "Expenses",
|
||||
});
|
||||
} else {
|
||||
console.log('[Dashboard] Hiding expenses from menu (user role:', userGroups, ')');
|
||||
}
|
||||
|
||||
// Add admin menu items if user is admin
|
||||
if (userIsAdmin) {
|
||||
console.log('[Dashboard] Adding admin console to interest menu');
|
||||
baseMenu.push({
|
||||
to: "/dashboard/admin",
|
||||
icon: "mdi-shield-crown",
|
||||
title: "Admin Console",
|
||||
});
|
||||
}
|
||||
|
||||
return baseMenu;
|
||||
});
|
||||
|
||||
const defaultMenu = computed(() => {
|
||||
const userIsAdmin = isAdmin();
|
||||
const userGroups = getUserGroups();
|
||||
|
||||
console.log('[Dashboard] Computing default menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
|
||||
|
||||
const baseMenu = [
|
||||
{
|
||||
to: "/dashboard/site",
|
||||
icon: "mdi-view-dashboard",
|
||||
title: "Site Analytics",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/data",
|
||||
icon: "mdi-finance",
|
||||
title: "Data Analytics",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/file-browser",
|
||||
icon: "mdi-folder",
|
||||
title: "File Browser",
|
||||
},
|
||||
];
|
||||
|
||||
// Add admin menu items if user is admin
|
||||
if (userIsAdmin) {
|
||||
console.log('[Dashboard] Adding admin console to default menu');
|
||||
baseMenu.push({
|
||||
to: "/dashboard/admin",
|
||||
icon: "mdi-shield-crown",
|
||||
title: "Admin Console",
|
||||
});
|
||||
}
|
||||
|
||||
return baseMenu;
|
||||
});
|
||||
|
||||
const menu = computed(() => {
|
||||
try {
|
||||
const tagsValue = toValue(tags);
|
||||
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
|
||||
|
||||
console.log('[Dashboard] Computing menu:', {
|
||||
hasInterestTag: tagsValue.interest,
|
||||
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
|
||||
menuIsArray: Array.isArray(menuToUse),
|
||||
menuLength: menuToUse?.length
|
||||
});
|
||||
|
||||
return menuToUse;
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error computing menu:', error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Safe menu wrapper to prevent crashes when menu is undefined
|
||||
const safeMenu = computed(() => {
|
||||
try {
|
||||
const currentMenu = menu.value;
|
||||
if (Array.isArray(currentMenu)) {
|
||||
return currentMenu;
|
||||
}
|
||||
|
||||
console.warn('[Dashboard] Menu is not an array, returning fallback menu');
|
||||
|
||||
// Get current user permissions for fallback menu
|
||||
const userIsAdmin = isAdmin();
|
||||
const userGroups = getUserGroups();
|
||||
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
|
||||
|
||||
// Fallback menu with essential items (respecting permissions)
|
||||
const fallbackMenu = [
|
||||
{
|
||||
to: "/dashboard/interest-list",
|
||||
icon: "mdi-view-list",
|
||||
title: "Interest List",
|
||||
},
|
||||
{
|
||||
to: "/dashboard/file-browser",
|
||||
icon: "mdi-folder",
|
||||
title: "File Browser",
|
||||
},
|
||||
];
|
||||
|
||||
// Only add expenses if user has sales/admin access
|
||||
if (hasSalesAccess) {
|
||||
fallbackMenu.push({
|
||||
to: "/dashboard/expenses",
|
||||
icon: "mdi-receipt",
|
||||
title: "Expenses",
|
||||
});
|
||||
}
|
||||
|
||||
// Only add admin console if user is admin
|
||||
if (userIsAdmin) {
|
||||
fallbackMenu.push({
|
||||
to: "/dashboard/admin",
|
||||
icon: "mdi-shield-crown",
|
||||
title: "Admin Console",
|
||||
});
|
||||
}
|
||||
|
||||
return fallbackMenu;
|
||||
} catch (error) {
|
||||
console.error('[Dashboard] Error computing menu:', error);
|
||||
|
||||
// Emergency fallback menu - only essential items
|
||||
return [
|
||||
{
|
||||
to: "/dashboard/interest-list",
|
||||
icon: "mdi-view-list",
|
||||
title: "Interest List",
|
||||
},
|
||||
];
|
||||
}
|
||||
});
|
||||
|
||||
const logOut = async () => {
|
||||
await logout();
|
||||
return navigateTo("/login");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (mdAndDown.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
drawer.value = true;
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -238,6 +238,7 @@ import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authentication', 'authorization'],
|
||||
layout: 'dashboard-unified',
|
||||
auth: {
|
||||
roles: ['admin']
|
||||
}
|
||||
|
||||
@@ -230,6 +230,7 @@ import { formatTime, formatDateTime } from '@/utils/dateUtils'
|
||||
|
||||
definePageMeta({
|
||||
middleware: ['authentication', 'authorization'],
|
||||
layout: 'dashboard-unified',
|
||||
auth: {
|
||||
roles: ['admin']
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard-unified'
|
||||
});
|
||||
|
||||
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<v-card class="mb-6">
|
||||
<v-card-text class="pa-6">
|
||||
<v-row align="center" class="mb-0">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-text-field
|
||||
v-model="filters.startDate"
|
||||
type="date"
|
||||
@@ -32,11 +32,11 @@
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@change="fetchExpenses"
|
||||
class="date-input-fix"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-text-field
|
||||
v-model="filters.endDate"
|
||||
type="date"
|
||||
@@ -44,11 +44,11 @@
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
@change="fetchExpenses"
|
||||
class="date-input-fix"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-select
|
||||
v-model="filters.category"
|
||||
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
||||
@@ -57,15 +57,27 @@
|
||||
density="comfortable"
|
||||
hide-details
|
||||
clearable
|
||||
@update:model-value="fetchExpenses"
|
||||
/>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-btn
|
||||
@click="fetchExpenses"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="large"
|
||||
class="w-100"
|
||||
prepend-icon="mdi-magnify"
|
||||
>
|
||||
Apply
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" md="2">
|
||||
<v-btn
|
||||
@click="resetToCurrentMonth"
|
||||
variant="outlined"
|
||||
size="large"
|
||||
size="default"
|
||||
class="w-100"
|
||||
>
|
||||
Current Month
|
||||
@@ -187,7 +199,7 @@
|
||||
<div class="d-flex flex-wrap align-center">
|
||||
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
|
||||
|
||||
<div class="d-flex gap-4">
|
||||
<div class="d-flex ga-4">
|
||||
<v-btn
|
||||
@click="exportCSV"
|
||||
:disabled="selectedExpenses.length === 0"
|
||||
@@ -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>
|
||||
|
||||
@@ -336,6 +336,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({
|
||||
layout: 'dashboard-unified'
|
||||
});
|
||||
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import FileUploader from '~/components/FileUploader.vue';
|
||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||
|
||||
@@ -116,12 +116,12 @@
|
||||
</v-card-title>
|
||||
<v-divider />
|
||||
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
|
||||
<div class="d-flex flex-column gap-6">
|
||||
<div class="d-flex flex-column">
|
||||
<v-card
|
||||
v-for="berth in getBerthsByStatus(status.value)"
|
||||
:key="berth.Id"
|
||||
@click="handleBerthClick(berth)"
|
||||
class="berth-kanban-card"
|
||||
class="berth-kanban-card mb-4"
|
||||
:color="status.color"
|
||||
variant="tonal"
|
||||
elevation="0"
|
||||
@@ -137,14 +137,24 @@
|
||||
</div>
|
||||
<div class="d-flex justify-space-between align-center">
|
||||
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
||||
<v-chip
|
||||
v-if="getInterestedCount(berth)"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
>
|
||||
{{ getInterestedCount(berth) }} interested
|
||||
</v-chip>
|
||||
<v-tooltip v-if="getInterestedCount(berth)" location="top">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-chip
|
||||
v-bind="props"
|
||||
size="x-small"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
>
|
||||
{{ getInterestedCount(berth) }} interested
|
||||
</v-chip>
|
||||
</template>
|
||||
<div class="pa-2">
|
||||
<div class="text-subtitle-2 mb-1">Interested Parties:</div>
|
||||
<div v-for="party in berth['Interested Parties']" :key="party.Id" class="text-body-2">
|
||||
{{ party['Full Name'] }}
|
||||
</div>
|
||||
</div>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
67
pages/dashboard/sidebar-demo.vue
Normal file
67
pages/dashboard/sidebar-demo.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
129
plugins/02.auth-error-handler.client.ts
Normal file
129
plugins/02.auth-error-handler.client.ts
Normal 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
2
remove.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
mkdir: cannot create directory ‘C:\\Users\\mpcia\\Documents\\Cline\\MCP’: File exists
|
||||
hello
|
||||
@@ -1,8 +1,9 @@
|
||||
import { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth';
|
||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[DUPLICATES] Find duplicates request');
|
||||
console.log('[ADMIN] Find duplicates request');
|
||||
|
||||
try {
|
||||
// Require sales or admin access for duplicate detection
|
||||
@@ -26,17 +27,27 @@ export default defineEventHandler(async (event) => {
|
||||
const interests = response.list || [];
|
||||
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
|
||||
|
||||
// Find potential duplicates
|
||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
||||
// Find duplicate groups using the new centralized utility
|
||||
const duplicateConfig = createInterestConfig();
|
||||
const duplicateGroups = findDuplicates(interests, duplicateConfig);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -100,8 +100,61 @@ export default defineEventHandler(async (event) => {
|
||||
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
|
||||
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
|
||||
|
||||
// Redirect to dashboard
|
||||
await sendRedirect(event, '/dashboard')
|
||||
// Return HTML with client-side redirect for SPA compatibility
|
||||
setHeader(event, 'Content-Type', 'text/html')
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful - Port Nimara Portal</title>
|
||||
<meta http-equiv="refresh" content="0;url=/dashboard">
|
||||
<script>
|
||||
// Immediate redirect
|
||||
window.location.href = '/dashboard';
|
||||
</script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #387bca 0%, #2c5aa0 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.spinner {
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 3px solid white;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="spinner"></div>
|
||||
<h2>Authentication successful!</h2>
|
||||
<p>Redirecting to dashboard...</p>
|
||||
<p><small>If you are not redirected automatically, <a href="/dashboard" style="color: #ffffff;">click here</a>.</small></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
} catch (error: any) {
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
@@ -2,14 +2,15 @@ import { keycloakClient } from '~/server/utils/keycloak-client'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const startTime = Date.now()
|
||||
console.log('[REFRESH] Processing token refresh request')
|
||||
const requestId = Math.random().toString(36).substring(7)
|
||||
console.log(`[REFRESH:${requestId}] Processing token refresh request`)
|
||||
|
||||
try {
|
||||
// Get current session
|
||||
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
||||
|
||||
if (!oidcSession) {
|
||||
console.error('[REFRESH] No session found')
|
||||
console.error(`[REFRESH:${requestId}] No session found`)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No session found'
|
||||
@@ -20,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
sessionData = JSON.parse(oidcSession)
|
||||
} catch (parseError) {
|
||||
console.error('[REFRESH] Failed to parse session:', parseError)
|
||||
console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Invalid session format'
|
||||
@@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// Check if we have a refresh token
|
||||
if (!sessionData.refreshToken) {
|
||||
console.error('[REFRESH] No refresh token available')
|
||||
console.error(`[REFRESH:${requestId}] No refresh token available`)
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'No refresh token available'
|
||||
@@ -39,24 +40,48 @@ export default defineEventHandler(async (event) => {
|
||||
// Validate environment variables
|
||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
||||
if (!clientSecret) {
|
||||
console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
|
||||
console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Authentication service misconfigured'
|
||||
})
|
||||
}
|
||||
|
||||
// Use refresh token to get new access token with retry logic
|
||||
console.log('[REFRESH] Using Keycloak client for token refresh...')
|
||||
// Use refresh token to get new access token with enhanced error handling
|
||||
console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`)
|
||||
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
||||
.catch((error: any) => {
|
||||
// Check if it's a transient error
|
||||
if (error.statusMessage === 'KEYCLOAK_TEMPORARILY_UNAVAILABLE') {
|
||||
console.log(`[REFRESH:${requestId}] Keycloak temporarily unavailable, using grace period`)
|
||||
// Return current session with extended grace period
|
||||
return {
|
||||
success: true,
|
||||
expiresAt: sessionData.expiresAt,
|
||||
gracePeriod: true
|
||||
}
|
||||
}
|
||||
throw error // Re-throw for permanent failures
|
||||
})
|
||||
|
||||
const refreshDuration = Date.now() - startTime
|
||||
console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, {
|
||||
console.log(`[REFRESH:${requestId}] Token refresh successful in ${refreshDuration}ms:`, {
|
||||
hasAccessToken: !!tokenResponse.access_token,
|
||||
hasRefreshToken: !!tokenResponse.refresh_token,
|
||||
expiresIn: tokenResponse.expires_in
|
||||
expiresIn: tokenResponse.expires_in,
|
||||
gracePeriod: tokenResponse.gracePeriod
|
||||
})
|
||||
|
||||
// Handle grace period response
|
||||
if (tokenResponse.gracePeriod) {
|
||||
console.log(`[REFRESH:${requestId}] Using grace period - session extended`)
|
||||
return {
|
||||
success: true,
|
||||
expiresAt: tokenResponse.expiresAt,
|
||||
gracePeriod: true
|
||||
}
|
||||
}
|
||||
|
||||
// Update session with new tokens
|
||||
const updatedSessionData = {
|
||||
...sessionData,
|
||||
@@ -79,7 +104,7 @@ export default defineEventHandler(async (event) => {
|
||||
path: '/'
|
||||
})
|
||||
|
||||
console.log('[REFRESH] Session updated successfully')
|
||||
console.log(`[REFRESH:${requestId}] Session updated successfully`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -87,14 +112,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('[REFRESH] Token refresh failed:', error)
|
||||
console.error(`[REFRESH:${requestId}] Token refresh failed:`, error)
|
||||
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
// Only clear session for permanent failures
|
||||
if (error.statusMessage === 'REFRESH_TOKEN_INVALID') {
|
||||
console.log(`[REFRESH:${requestId}] Clearing session due to invalid refresh token`)
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
export default defineEventHandler(async (event) => {
|
||||
console.log('[SESSION] Checking authentication session...')
|
||||
const requestId = Math.random().toString(36).substring(7)
|
||||
const startTime = Date.now()
|
||||
console.log(`[SESSION:${requestId}] Checking authentication session...`)
|
||||
|
||||
// Check OIDC/Keycloak authentication only
|
||||
try {
|
||||
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
||||
|
||||
if (!oidcSessionCookie) {
|
||||
console.log('[SESSION] No OIDC session cookie found')
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
console.log(`[SESSION:${requestId}] No OIDC session cookie found`)
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'NO_SESSION_COOKIE',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[SESSION] OIDC session cookie found, parsing...')
|
||||
console.log(`[SESSION:${requestId}] OIDC session cookie found, parsing...`)
|
||||
|
||||
let sessionData
|
||||
try {
|
||||
// Parse the session data
|
||||
const parseStart = Date.now()
|
||||
sessionData = JSON.parse(oidcSessionCookie)
|
||||
console.log('[SESSION] Session data parsed successfully:', {
|
||||
const parseTime = Date.now() - parseStart
|
||||
|
||||
console.log(`[SESSION:${requestId}] Session data parsed successfully in ${parseTime}ms:`, {
|
||||
hasUser: !!sessionData.user,
|
||||
hasAccessToken: !!sessionData.accessToken,
|
||||
hasIdToken: !!sessionData.idToken,
|
||||
@@ -25,19 +36,25 @@ export default defineEventHandler(async (event) => {
|
||||
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
||||
})
|
||||
} catch (parseError) {
|
||||
console.error('[SESSION] Failed to parse session cookie:', parseError)
|
||||
console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError)
|
||||
// Clear invalid session
|
||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'INVALID_SESSION_FORMAT',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
// Validate session structure
|
||||
if (!sessionData.user || !sessionData.accessToken) {
|
||||
console.error('[SESSION] Invalid session structure:', {
|
||||
console.error(`[SESSION:${requestId}] Invalid session structure:`, {
|
||||
hasUser: !!sessionData.user,
|
||||
hasAccessToken: !!sessionData.accessToken
|
||||
})
|
||||
@@ -46,12 +63,18 @@ export default defineEventHandler(async (event) => {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'INVALID_SESSION_STRUCTURE',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
// Check if session is still valid
|
||||
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
||||
console.log('[SESSION] Session expired:', {
|
||||
console.log(`[SESSION:${requestId}] Session expired:`, {
|
||||
expiresAt: sessionData.expiresAt,
|
||||
currentTime: Date.now(),
|
||||
expiredSince: Date.now() - sessionData.expiresAt
|
||||
@@ -62,7 +85,13 @@ export default defineEventHandler(async (event) => {
|
||||
domain: cookieDomain,
|
||||
path: '/'
|
||||
})
|
||||
return { user: null, authenticated: false, groups: [] }
|
||||
return {
|
||||
user: null,
|
||||
authenticated: false,
|
||||
groups: [],
|
||||
reason: 'SESSION_EXPIRED',
|
||||
requestId
|
||||
}
|
||||
}
|
||||
|
||||
// Extract groups from ID token
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
417
server/utils/duplicate-detection.ts
Normal file
417
server/utils/duplicate-detection.ts
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
272
server/utils/session-manager.ts
Normal file
272
server/utils/session-manager.ts
Normal 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 }
|
||||
268
tests/auth/session-manager.test.ts
Normal file
268
tests/auth/session-manager.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user