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
|
# Documenso Configuration
|
||||||
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
|
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
|
||||||
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
|
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 Configuration for Embedded Signing
|
||||||
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ logs
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
nul
|
||||||
|
|||||||
4
app.vue
4
app.vue
@@ -1,5 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<NuxtPwaManifest />
|
<NuxtPwaManifest />
|
||||||
<NuxtPage />
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
<GlobalToast />
|
<GlobalToast />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@
|
|||||||
{{ getSignatureStatusText('cc') }}
|
{{ getSignatureStatusText('cc') }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</v-list-item-title>
|
</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>
|
<template v-slot:append>
|
||||||
<div class="d-flex gap-1">
|
<div class="d-flex gap-1">
|
||||||
<v-btn
|
<v-btn
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="d-flex align-center">
|
<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>
|
<span>Add New Expense</span>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -19,44 +19,35 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-form ref="form" @submit.prevent="saveExpense">
|
<v-form ref="form" @submit.prevent="handleSubmit">
|
||||||
<v-row>
|
<v-row>
|
||||||
<!-- Merchant/Description -->
|
<!-- Establishment Name -->
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.merchant"
|
v-model="expense.establishmentName"
|
||||||
label="Merchant/Description"
|
label="Establishment Name"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
required
|
required
|
||||||
|
placeholder="e.g., Shell, American Airlines, etc."
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Amount and Currency -->
|
<!-- Price -->
|
||||||
<v-col cols="8">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.amount"
|
v-model="expense.price"
|
||||||
label="Amount"
|
label="Price"
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required, rules.positive]"
|
:rules="[rules.required, rules.price]"
|
||||||
required
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="4">
|
|
||||||
<v-select
|
|
||||||
v-model="expense.currency"
|
|
||||||
:items="currencies"
|
|
||||||
label="Currency"
|
|
||||||
variant="outlined"
|
|
||||||
:rules="[rules.required]"
|
|
||||||
required
|
required
|
||||||
|
placeholder="e.g., 59.95"
|
||||||
|
prepend-inner-icon="mdi-currency-eur"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Category -->
|
<!-- Category -->
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="expense.category"
|
v-model="expense.category"
|
||||||
:items="categories"
|
:items="categories"
|
||||||
@@ -68,60 +59,49 @@
|
|||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Payer -->
|
<!-- Payer -->
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" sm="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.payer"
|
v-model="expense.payer"
|
||||||
label="Payer"
|
label="Payer"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
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>
|
</v-col>
|
||||||
|
|
||||||
<!-- Date -->
|
<!-- Date -->
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="expense.date"
|
v-model="expense.date"
|
||||||
label="Date"
|
|
||||||
type="date"
|
type="date"
|
||||||
|
label="Date"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
:rules="[rules.required]"
|
:rules="[rules.required]"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<!-- Time -->
|
<!-- Contents/Description -->
|
||||||
<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 -->
|
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-model="expense.notes"
|
v-model="expense.contents"
|
||||||
label="Notes (Optional)"
|
label="Description (optional)"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
rows="3"
|
rows="3"
|
||||||
auto-grow
|
placeholder="Additional details about the expense..."
|
||||||
/>
|
|
||||||
</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
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -133,17 +113,19 @@
|
|||||||
<v-btn
|
<v-btn
|
||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
variant="text"
|
variant="text"
|
||||||
:disabled="saving"
|
:disabled="creating"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="saveExpense"
|
@click="handleSubmit"
|
||||||
|
:disabled="creating"
|
||||||
color="primary"
|
color="primary"
|
||||||
:loading="saving"
|
:loading="creating"
|
||||||
:disabled="!isValid"
|
|
||||||
>
|
>
|
||||||
Add Expense
|
<v-icon v-if="!creating" class="mr-1">mdi-plus</v-icon>
|
||||||
|
Create Expense
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
@@ -151,82 +133,81 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, nextTick } from 'vue';
|
import { ref, computed, watch } from 'vue';
|
||||||
import type { Expense } from '@/utils/types';
|
|
||||||
|
|
||||||
|
// Props
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue: boolean;
|
modelValue: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Emits {
|
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
(e: 'created', expense: Expense): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
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({
|
const dialog = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => emit('update:modelValue', value)
|
set: (value) => emit('update:modelValue', value)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reactive state
|
||||||
const form = ref();
|
const form = ref();
|
||||||
const saving = ref(false);
|
const creating = ref(false);
|
||||||
|
|
||||||
// Form data
|
|
||||||
const expense = ref({
|
const expense = ref({
|
||||||
merchant: '',
|
establishmentName: '',
|
||||||
amount: '',
|
price: '',
|
||||||
currency: 'EUR',
|
|
||||||
category: '',
|
category: '',
|
||||||
payer: '',
|
payer: '',
|
||||||
|
paymentMethod: '',
|
||||||
date: '',
|
date: '',
|
||||||
time: '',
|
contents: ''
|
||||||
notes: '',
|
|
||||||
receipt: null as File[] | null
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form options
|
// Form options
|
||||||
const currencies = ['EUR', 'USD', 'GBP', 'AUD', 'CAD', 'CHF', 'SEK', 'NOK', 'DKK'];
|
const categories = [
|
||||||
const categories = ['Food/Drinks', 'Shop', 'Online', 'Transportation', 'Accommodation', 'Entertainment', 'Other'];
|
'Food/Drinks',
|
||||||
|
'Shop',
|
||||||
|
'Online',
|
||||||
|
'Other'
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentMethods = [
|
||||||
|
'Card',
|
||||||
|
'Cash',
|
||||||
|
'N/A'
|
||||||
|
];
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: any) => !!value || 'This field is required',
|
required: (value: string) => !!value || 'This field is required',
|
||||||
positive: (value: any) => {
|
price: (value: string) => {
|
||||||
|
if (!value) return 'Price is required';
|
||||||
const num = parseFloat(value);
|
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
|
// Methods
|
||||||
|
const closeModal = () => {
|
||||||
|
dialog.value = false;
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
const now = new Date();
|
|
||||||
expense.value = {
|
expense.value = {
|
||||||
merchant: '',
|
establishmentName: '',
|
||||||
amount: '',
|
price: '',
|
||||||
currency: 'EUR',
|
|
||||||
category: '',
|
category: '',
|
||||||
payer: '',
|
payer: '',
|
||||||
date: now.toISOString().slice(0, 10),
|
paymentMethod: '',
|
||||||
time: now.toTimeString().slice(0, 5),
|
date: '',
|
||||||
notes: '',
|
contents: ''
|
||||||
receipt: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (form.value) {
|
if (form.value) {
|
||||||
@@ -234,89 +215,56 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const handleSubmit = async () => {
|
||||||
if (!saving.value) {
|
|
||||||
dialog.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveExpense = async () => {
|
|
||||||
if (!form.value) return;
|
if (!form.value) return;
|
||||||
|
|
||||||
const { valid } = await form.value.validate();
|
const { valid } = await form.value.validate();
|
||||||
if (!valid) return;
|
if (!valid) return;
|
||||||
|
|
||||||
saving.value = true;
|
creating.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Combine date and time for the API
|
// Create expense via API
|
||||||
const dateTime = `${expense.value.date}T${expense.value.time}:00`;
|
const response = await $fetch<{
|
||||||
|
success: boolean;
|
||||||
// Prepare the expense data
|
data?: any;
|
||||||
const expenseData = {
|
message?: string;
|
||||||
"Establishment Name": expense.value.merchant,
|
}>('/api/create-expense', {
|
||||||
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', {
|
|
||||||
method: 'POST',
|
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);
|
if (response.success) {
|
||||||
|
emit('created', response.data);
|
||||||
// Emit the created event
|
closeModal();
|
||||||
emit('created', response);
|
}
|
||||||
|
|
||||||
// Close the modal
|
|
||||||
dialog.value = false;
|
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[ExpenseCreateModal] Error creating expense:', error);
|
console.error('[ExpenseCreateModal] Error creating expense:', error);
|
||||||
|
// Handle error display here if needed
|
||||||
// Show error message (you might want to use a toast notification here)
|
|
||||||
alert('Failed to create expense. Please try again.');
|
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
creating.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Watch for modal open/close
|
// Watch for modal open to set default date
|
||||||
watch(dialog, (newValue) => {
|
watch(dialog, (isOpen) => {
|
||||||
if (newValue) {
|
if (isOpen && !expense.value.date) {
|
||||||
// Reset form when modal opens
|
expense.value.date = new Date().toISOString().slice(0, 10);
|
||||||
nextTick(() => {
|
|
||||||
resetForm();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize form with current date/time
|
|
||||||
onMounted(() => {
|
|
||||||
resetForm();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.v-dialog > .v-card {
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-form {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.v-card-actions {
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
{{ expense.DisplayPrice || expense.Price }}
|
{{ expense.DisplayPrice || expense.Price }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
|
<div v-if="expense.ConversionRate && expense.ConversionRate !== 1" class="conversion-info">
|
||||||
<span class="text-caption text-grey-darken-1">
|
<span class="text-caption text-grey-darken-3">
|
||||||
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
Rate: {{ expense.ConversionRate }} | USD: {{ expense.DisplayPriceUSD }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,12 +64,12 @@
|
|||||||
|
|
||||||
<!-- Multiple receipts indicator -->
|
<!-- Multiple receipts indicator -->
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="expense.Receipt.length > 1"
|
|
||||||
size="x-small"
|
size="x-small"
|
||||||
color="primary"
|
variant="flat"
|
||||||
class="receipt-count-chip"
|
:color="getCategoryColor(expense.Category)"
|
||||||
|
class="text-caption text-grey-darken-3"
|
||||||
>
|
>
|
||||||
+{{ expense.Receipt.length - 1 }}
|
{{ expense.Category || 'Other' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -215,7 +215,7 @@
|
|||||||
<span class="text-caption">Delete</span>
|
<span class="text-caption">Delete</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="12">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
@@ -848,7 +848,7 @@ const handleFormSubmit = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveInterest = async (isAutoSave = false) => {
|
const saveInterest = async (isAutoSave = false, closeAfterSave = false) => {
|
||||||
if (interest.value) {
|
if (interest.value) {
|
||||||
isSaving.value = true;
|
isSaving.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -871,7 +871,11 @@ const saveInterest = async (isAutoSave = false) => {
|
|||||||
if (!isAutoSave) {
|
if (!isAutoSave) {
|
||||||
toast.success("Interest saved successfully!");
|
toast.success("Interest saved successfully!");
|
||||||
emit("save", interest.value);
|
emit("save", interest.value);
|
||||||
closeModal();
|
|
||||||
|
// Only close if explicitly requested
|
||||||
|
if (closeAfterSave) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// For auto-save, just emit save to refresh parent
|
// For auto-save, just emit save to refresh parent
|
||||||
emit("save", interest.value);
|
emit("save", interest.value);
|
||||||
|
|||||||
@@ -50,15 +50,16 @@ const checkForDuplicates = async () => {
|
|||||||
try {
|
try {
|
||||||
loading.value = true;
|
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;
|
let canViewDuplicates = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
canViewDuplicates = await hasRole(['sales', 'admin']);
|
canViewDuplicates = isSalesOrAdmin(); // Use the convenience method
|
||||||
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
|
console.log('[InterestDuplicateNotification] Role check result:', canViewDuplicates);
|
||||||
} catch (roleError) {
|
} catch (roleError) {
|
||||||
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
|
console.error('[InterestDuplicateNotification] Role check failed:', roleError);
|
||||||
// Try to get user info directly as fallback
|
// Try to get user info directly as fallback
|
||||||
const { isAdmin } = useAuthorization();
|
|
||||||
canViewDuplicates = isAdmin();
|
canViewDuplicates = isAdmin();
|
||||||
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
|
console.log('[InterestDuplicateNotification] Fallback admin check:', canViewDuplicates);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,19 @@
|
|||||||
</template>
|
</template>
|
||||||
</v-checkbox>
|
</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-checkbox
|
||||||
v-model="options.includeProcessingFee"
|
v-model="options.includeProcessingFee"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -119,8 +132,21 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</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 -->
|
<!-- Page Format -->
|
||||||
<v-col cols="12">
|
<v-col cols="12" md="6">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="options.pageFormat"
|
v-model="options.pageFormat"
|
||||||
:items="pageFormatOptions"
|
:items="pageFormatOptions"
|
||||||
@@ -204,10 +230,12 @@ interface PDFOptions {
|
|||||||
subheader: string;
|
subheader: string;
|
||||||
groupBy: 'none' | 'payer' | 'category' | 'date';
|
groupBy: 'none' | 'payer' | 'category' | 'date';
|
||||||
includeReceipts: boolean;
|
includeReceipts: boolean;
|
||||||
|
includeReceiptContents: boolean;
|
||||||
includeSummary: boolean;
|
includeSummary: boolean;
|
||||||
includeDetails: boolean;
|
includeDetails: boolean;
|
||||||
includeProcessingFee: boolean;
|
includeProcessingFee: boolean;
|
||||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
|
targetCurrency: 'USD' | 'EUR';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Computed dialog model
|
// Computed dialog model
|
||||||
@@ -225,10 +253,12 @@ const options = ref<PDFOptions>({
|
|||||||
subheader: '',
|
subheader: '',
|
||||||
groupBy: 'payer',
|
groupBy: 'payer',
|
||||||
includeReceipts: true,
|
includeReceipts: true,
|
||||||
|
includeReceiptContents: true,
|
||||||
includeSummary: true,
|
includeSummary: true,
|
||||||
includeDetails: true,
|
includeDetails: true,
|
||||||
includeProcessingFee: true,
|
includeProcessingFee: true,
|
||||||
pageFormat: 'A4'
|
pageFormat: 'A4',
|
||||||
|
targetCurrency: 'EUR'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Form options
|
// Form options
|
||||||
@@ -245,6 +275,11 @@ const pageFormatOptions = [
|
|||||||
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
{ text: 'Legal (8.5 × 14 in)', value: 'Legal' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currencyOptions = [
|
||||||
|
{ text: 'Euro (EUR)', value: 'EUR' },
|
||||||
|
{ text: 'US Dollar (USD)', value: 'USD' }
|
||||||
|
];
|
||||||
|
|
||||||
// Validation rules
|
// Validation rules
|
||||||
const rules = {
|
const rules = {
|
||||||
required: (value: string) => !!value || 'This field is required'
|
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) => {
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
// Skip auth for SSR
|
// Skip auth for SSR
|
||||||
if (import.meta.server) return;
|
if (import.meta.server) return;
|
||||||
@@ -17,56 +19,59 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
|
|
||||||
console.log('[MIDDLEWARE] Checking authentication for route:', to.path);
|
console.log('[MIDDLEWARE] Checking authentication for route:', to.path);
|
||||||
|
|
||||||
// Use a cached auth state to avoid excessive API calls
|
// Use session manager for centralized session handling
|
||||||
const nuxtApp = useNuxtApp();
|
const nuxtApp = useNuxtApp();
|
||||||
const cacheKey = 'auth:session:cache';
|
const cacheKey = 'auth:session:cache';
|
||||||
const cacheExpiry = 30000; // 30 seconds cache
|
const baseExpiry = 3 * 60 * 1000; // 3 minutes base cache
|
||||||
|
const jitter = Math.floor(Math.random() * 10000); // 0-10 seconds jitter
|
||||||
// Check if we have a cached session
|
const cacheExpiry = baseExpiry + jitter; // Prevent thundering herd
|
||||||
const cachedSession = nuxtApp.payload.data?.[cacheKey];
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
if (cachedSession && cachedSession.timestamp && (now - cachedSession.timestamp) < cacheExpiry) {
|
|
||||||
console.log('[MIDDLEWARE] Using cached session');
|
|
||||||
if (cachedSession.authenticated && cachedSession.user) {
|
|
||||||
// Store auth state for components
|
|
||||||
if (!nuxtApp.payload.data) {
|
|
||||||
nuxtApp.payload.data = {};
|
|
||||||
}
|
|
||||||
nuxtApp.payload.data.authState = {
|
|
||||||
user: cachedSession.user,
|
|
||||||
authenticated: cachedSession.authenticated,
|
|
||||||
groups: cachedSession.groups || []
|
|
||||||
};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return navigateTo('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check Keycloak authentication via session API with timeout
|
// Use SessionManager for deduped session checks
|
||||||
const controller = new AbortController();
|
const sessionData = await sessionManager.checkSession({
|
||||||
const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
nuxtApp,
|
||||||
|
cacheKey,
|
||||||
|
cacheExpiry,
|
||||||
|
fetchFn: async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||||
|
|
||||||
const sessionData = await $fetch('/api/auth/session', {
|
try {
|
||||||
signal: controller.signal,
|
const result = await $fetch('/api/auth/session', {
|
||||||
retry: 1,
|
signal: controller.signal,
|
||||||
retryDelay: 500
|
retry: 2,
|
||||||
}) as any;
|
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);
|
clearTimeout(timeout);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cache the session data
|
// Store auth state for components
|
||||||
if (!nuxtApp.payload.data) {
|
if (!nuxtApp.payload.data) {
|
||||||
nuxtApp.payload.data = {};
|
nuxtApp.payload.data = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
nuxtApp.payload.data[cacheKey] = {
|
|
||||||
...sessionData,
|
|
||||||
timestamp: now
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store auth state for components
|
|
||||||
nuxtApp.payload.data.authState = {
|
nuxtApp.payload.data.authState = {
|
||||||
user: sessionData.user,
|
user: sessionData.user,
|
||||||
authenticated: sessionData.authenticated,
|
authenticated: sessionData.authenticated,
|
||||||
@@ -77,7 +82,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
authenticated: sessionData.authenticated,
|
authenticated: sessionData.authenticated,
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
userId: sessionData.user?.id,
|
userId: sessionData.user?.id,
|
||||||
groups: sessionData.groups || []
|
groups: sessionData.groups || [],
|
||||||
|
fromCache: sessionData.fromCache,
|
||||||
|
reason: sessionData.reason
|
||||||
});
|
});
|
||||||
|
|
||||||
if (sessionData.authenticated && sessionData.user) {
|
if (sessionData.authenticated && sessionData.user) {
|
||||||
@@ -99,25 +106,10 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[MIDDLEWARE] Auth check failed:', error);
|
console.error('[MIDDLEWARE] Auth check failed:', error);
|
||||||
|
|
||||||
// If it's a network error or timeout, check if we have a recent cached session
|
// Show warning for cached results due to network errors
|
||||||
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
|
if (error.reason === 'NETWORK_ERROR_CACHED') {
|
||||||
console.log('[MIDDLEWARE] Network error, checking for recent cache');
|
const toast = useToast();
|
||||||
const recentCache = nuxtApp.payload.data?.[cacheKey];
|
toast.warning('Network connectivity issue - using cached authentication');
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return navigateTo('/login');
|
return navigateTo('/login');
|
||||||
|
|||||||
@@ -10,17 +10,29 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles);
|
console.log('[AUTHORIZATION] Checking route access for:', to.path, 'Required roles:', to.meta.roles);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current session data with groups
|
// Get auth state from authentication middleware (already cached)
|
||||||
const sessionData = await $fetch('/api/auth/session') as any;
|
const nuxtApp = useNuxtApp();
|
||||||
|
const authState = nuxtApp.payload?.data?.authState;
|
||||||
|
|
||||||
if (!sessionData.authenticated || !sessionData.user) {
|
// If auth state not available, authentication middleware hasn't run or failed
|
||||||
console.log('[AUTHORIZATION] User not authenticated, redirecting to login');
|
if (!authState || !authState.authenticated || !authState.user) {
|
||||||
return navigateTo('/login');
|
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
|
// Get required roles for this route
|
||||||
const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles];
|
const requiredRoles = Array.isArray(to.meta.roles) ? to.meta.roles : [to.meta.roles];
|
||||||
const userGroups = sessionData.groups || [];
|
const userGroups = authState.groups || [];
|
||||||
|
|
||||||
// Check if user has any of the required roles
|
// Check if user has any of the required roles
|
||||||
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
|
const hasRequiredRole = requiredRoles.some(role => userGroups.includes(role));
|
||||||
@@ -29,29 +41,20 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
|||||||
console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
|
console.log('[AUTHORIZATION] Access denied. User groups:', userGroups, 'Required roles:', requiredRoles);
|
||||||
|
|
||||||
// Store the error in nuxtApp to show toast on redirect
|
// Store the error in nuxtApp to show toast on redirect
|
||||||
const nuxtApp = useNuxtApp();
|
|
||||||
nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`;
|
nuxtApp.payload.authError = `Access denied. This page requires one of the following roles: ${requiredRoles.join(', ')}`;
|
||||||
|
|
||||||
// Redirect to dashboard instead of login since user is authenticated
|
// Redirect to dashboard instead of login since user is authenticated
|
||||||
return navigateTo('/dashboard');
|
return navigateTo('/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store auth state in nuxtApp for use by components
|
|
||||||
const nuxtApp = useNuxtApp();
|
|
||||||
if (!nuxtApp.payload.data) {
|
|
||||||
nuxtApp.payload.data = {};
|
|
||||||
}
|
|
||||||
nuxtApp.payload.data.authState = {
|
|
||||||
user: sessionData.user,
|
|
||||||
authenticated: sessionData.authenticated,
|
|
||||||
groups: sessionData.groups || []
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[AUTHORIZATION] Access granted for route:', to.path);
|
console.log('[AUTHORIZATION] Access granted for route:', to.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTHORIZATION] Error checking route access:', error);
|
console.error('[AUTHORIZATION] Error checking route access:', error);
|
||||||
|
|
||||||
// If session check fails, redirect to login
|
// Don't automatically redirect to login on errors
|
||||||
return navigateTo('/login');
|
// Let the authentication middleware handle auth failures
|
||||||
|
const toast = useToast();
|
||||||
|
toast.error('Failed to verify permissions. Please try again.');
|
||||||
|
return navigateTo('/dashboard');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export default defineNuxtConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
navigateFallback: '/',
|
navigateFallback: '/',
|
||||||
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
globPatterns: ['**/*.{js,css,html,png,jpg,jpeg,svg,ico}'],
|
||||||
|
navigateFallbackDenylist: [/^\/api\//],
|
||||||
runtimeCaching: [
|
runtimeCaching: [
|
||||||
{
|
{
|
||||||
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
|
urlPattern: /^https:\/\/cms\.portnimara\.dev\/.*/i,
|
||||||
@@ -94,7 +95,9 @@ export default defineNuxtConfig({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
skipWaiting: true,
|
||||||
|
clientsClaim: true
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
installPrompt: true,
|
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"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/ui": "^3.2.0",
|
||||||
"@pdfme/common": "^5.4.0",
|
"@pdfme/common": "^5.4.0",
|
||||||
"@pdfme/generator": "^5.4.0",
|
"@pdfme/generator": "^5.4.0",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
"pdfkit": "^0.17.1",
|
||||||
"sharp": "^0.34.2",
|
"sharp": "^0.34.2",
|
||||||
"v-phone-input": "^4.4.2",
|
"v-phone-input": "^4.4.2",
|
||||||
"vue": "latest",
|
"vue": "latest",
|
||||||
@@ -35,6 +37,7 @@
|
|||||||
"@types/imap": "^0.8.42",
|
"@types/imap": "^0.8.42",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@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>
|
<template>
|
||||||
<v-app full-height>
|
<div>
|
||||||
<v-navigation-drawer
|
<!-- This page now acts as a parent route for dashboard pages -->
|
||||||
v-model="drawer"
|
<NuxtPage />
|
||||||
:location="mdAndDown ? 'bottom' : undefined"
|
</div>
|
||||||
>
|
|
||||||
<v-img v-if="!mdAndDown" src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="110" class="my-6" contain />
|
|
||||||
|
|
||||||
<v-list color="primary" lines="two">
|
|
||||||
<v-list-item
|
|
||||||
v-for="(item, index) in safeMenu"
|
|
||||||
:key="index"
|
|
||||||
:to="item.to"
|
|
||||||
:title="item.title"
|
|
||||||
:prepend-icon="item.icon"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
|
|
||||||
<template #append>
|
|
||||||
<v-list lines="two">
|
|
||||||
<v-list-item
|
|
||||||
v-if="user"
|
|
||||||
:title="user.name"
|
|
||||||
:subtitle="user.email"
|
|
||||||
prepend-icon="mdi-account"
|
|
||||||
>
|
|
||||||
<template #append>
|
|
||||||
<v-chip v-if="user.tier && user.tier !== 'basic'" size="small" color="primary">
|
|
||||||
{{ user.tier }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
</v-list-item>
|
|
||||||
<v-list-item
|
|
||||||
@click="logOut"
|
|
||||||
title="Log out"
|
|
||||||
prepend-icon="mdi-logout"
|
|
||||||
base-color="error"
|
|
||||||
/>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
</v-navigation-drawer>
|
|
||||||
|
|
||||||
<v-app-bar v-if="mdAndDown" elevation="2">
|
|
||||||
<template #prepend>
|
|
||||||
<v-app-bar-nav-icon variant="text" @click.stop="drawer = !drawer" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<v-img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="50" />
|
|
||||||
|
|
||||||
<template #append>
|
|
||||||
<v-btn
|
|
||||||
@click="logOut"
|
|
||||||
class="mr-3"
|
|
||||||
variant="text"
|
|
||||||
color="error"
|
|
||||||
icon="mdi-logout"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</v-app-bar>
|
|
||||||
|
|
||||||
<v-main>
|
|
||||||
<router-view />
|
|
||||||
</v-main>
|
|
||||||
</v-app>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["authentication"],
|
middleware: ["authentication"],
|
||||||
layout: false,
|
layout: "dashboard-unified",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mdAndDown } = useDisplay();
|
|
||||||
const { user, logout, authSource } = useUnifiedAuth();
|
|
||||||
const { isAdmin, getUserGroups, getCurrentUser } = useAuthorization();
|
|
||||||
const tags = usePortalTags();
|
|
||||||
|
|
||||||
const drawer = ref(false);
|
|
||||||
|
|
||||||
// Debug auth state
|
|
||||||
onMounted(() => {
|
|
||||||
nextTick(() => {
|
|
||||||
console.log('[Dashboard] Auth state on mount:', {
|
|
||||||
isAdmin: isAdmin(),
|
|
||||||
userGroups: getUserGroups(),
|
|
||||||
currentUser: getCurrentUser()
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const interestMenu = computed(() => {
|
|
||||||
const userIsAdmin = isAdmin();
|
|
||||||
const userGroups = getUserGroups();
|
|
||||||
|
|
||||||
console.log('[Dashboard] Computing interest menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
|
|
||||||
|
|
||||||
// Check if user has sales or admin privileges
|
|
||||||
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
|
|
||||||
|
|
||||||
const baseMenu = [
|
|
||||||
//{
|
|
||||||
// to: "/dashboard/interest-eoi-queue",
|
|
||||||
// icon: "mdi-tray-full",
|
|
||||||
// title: "EOI Queue",
|
|
||||||
//},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-analytics",
|
|
||||||
icon: "mdi-view-dashboard",
|
|
||||||
title: "Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-berth-list",
|
|
||||||
icon: "mdi-table",
|
|
||||||
title: "Berth List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-berth-status",
|
|
||||||
icon: "mdi-sail-boat",
|
|
||||||
title: "Berth Status",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-list",
|
|
||||||
icon: "mdi-view-list",
|
|
||||||
title: "Interest List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-status",
|
|
||||||
icon: "mdi-account-check",
|
|
||||||
title: "Interest Status",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/file-browser",
|
|
||||||
icon: "mdi-folder",
|
|
||||||
title: "File Browser",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only show expenses to sales and admin users
|
|
||||||
if (hasSalesAccess) {
|
|
||||||
console.log('[Dashboard] Adding expenses to menu (user has sales/admin access)');
|
|
||||||
baseMenu.push({
|
|
||||||
to: "/dashboard/expenses",
|
|
||||||
icon: "mdi-receipt",
|
|
||||||
title: "Expenses",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
console.log('[Dashboard] Hiding expenses from menu (user role:', userGroups, ')');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add admin menu items if user is admin
|
|
||||||
if (userIsAdmin) {
|
|
||||||
console.log('[Dashboard] Adding admin console to interest menu');
|
|
||||||
baseMenu.push({
|
|
||||||
to: "/dashboard/admin",
|
|
||||||
icon: "mdi-shield-crown",
|
|
||||||
title: "Admin Console",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseMenu;
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultMenu = computed(() => {
|
|
||||||
const userIsAdmin = isAdmin();
|
|
||||||
const userGroups = getUserGroups();
|
|
||||||
|
|
||||||
console.log('[Dashboard] Computing default menu - isAdmin:', userIsAdmin, 'groups:', userGroups);
|
|
||||||
|
|
||||||
const baseMenu = [
|
|
||||||
{
|
|
||||||
to: "/dashboard/site",
|
|
||||||
icon: "mdi-view-dashboard",
|
|
||||||
title: "Site Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/data",
|
|
||||||
icon: "mdi-finance",
|
|
||||||
title: "Data Analytics",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/file-browser",
|
|
||||||
icon: "mdi-folder",
|
|
||||||
title: "File Browser",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add admin menu items if user is admin
|
|
||||||
if (userIsAdmin) {
|
|
||||||
console.log('[Dashboard] Adding admin console to default menu');
|
|
||||||
baseMenu.push({
|
|
||||||
to: "/dashboard/admin",
|
|
||||||
icon: "mdi-shield-crown",
|
|
||||||
title: "Admin Console",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseMenu;
|
|
||||||
});
|
|
||||||
|
|
||||||
const menu = computed(() => {
|
|
||||||
try {
|
|
||||||
const tagsValue = toValue(tags);
|
|
||||||
const menuToUse = tagsValue.interest ? interestMenu.value : defaultMenu.value;
|
|
||||||
|
|
||||||
console.log('[Dashboard] Computing menu:', {
|
|
||||||
hasInterestTag: tagsValue.interest,
|
|
||||||
menuType: tagsValue.interest ? 'interestMenu' : 'defaultMenu',
|
|
||||||
menuIsArray: Array.isArray(menuToUse),
|
|
||||||
menuLength: menuToUse?.length
|
|
||||||
});
|
|
||||||
|
|
||||||
return menuToUse;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Dashboard] Error computing menu:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Safe menu wrapper to prevent crashes when menu is undefined
|
|
||||||
const safeMenu = computed(() => {
|
|
||||||
try {
|
|
||||||
const currentMenu = menu.value;
|
|
||||||
if (Array.isArray(currentMenu)) {
|
|
||||||
return currentMenu;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('[Dashboard] Menu is not an array, returning fallback menu');
|
|
||||||
|
|
||||||
// Get current user permissions for fallback menu
|
|
||||||
const userIsAdmin = isAdmin();
|
|
||||||
const userGroups = getUserGroups();
|
|
||||||
const hasSalesAccess = userGroups.includes('sales') || userGroups.includes('admin');
|
|
||||||
|
|
||||||
// Fallback menu with essential items (respecting permissions)
|
|
||||||
const fallbackMenu = [
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-list",
|
|
||||||
icon: "mdi-view-list",
|
|
||||||
title: "Interest List",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: "/dashboard/file-browser",
|
|
||||||
icon: "mdi-folder",
|
|
||||||
title: "File Browser",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only add expenses if user has sales/admin access
|
|
||||||
if (hasSalesAccess) {
|
|
||||||
fallbackMenu.push({
|
|
||||||
to: "/dashboard/expenses",
|
|
||||||
icon: "mdi-receipt",
|
|
||||||
title: "Expenses",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add admin console if user is admin
|
|
||||||
if (userIsAdmin) {
|
|
||||||
fallbackMenu.push({
|
|
||||||
to: "/dashboard/admin",
|
|
||||||
icon: "mdi-shield-crown",
|
|
||||||
title: "Admin Console",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallbackMenu;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Dashboard] Error computing menu:', error);
|
|
||||||
|
|
||||||
// Emergency fallback menu - only essential items
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
to: "/dashboard/interest-list",
|
|
||||||
icon: "mdi-view-list",
|
|
||||||
title: "Interest List",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const logOut = async () => {
|
|
||||||
await logout();
|
|
||||||
return navigateTo("/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (mdAndDown.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawer.value = true;
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ import { formatDate, formatTime, formatDateTime } from '@/utils/dateUtils'
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication', 'authorization'],
|
middleware: ['authentication', 'authorization'],
|
||||||
|
layout: 'dashboard-unified',
|
||||||
auth: {
|
auth: {
|
||||||
roles: ['admin']
|
roles: ['admin']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,7 @@ import { formatTime, formatDateTime } from '@/utils/dateUtils'
|
|||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication', 'authorization'],
|
middleware: ['authentication', 'authorization'],
|
||||||
|
layout: 'dashboard-unified',
|
||||||
auth: {
|
auth: {
|
||||||
roles: ['admin']
|
roles: ['admin']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'dashboard-unified'
|
||||||
|
});
|
||||||
|
|
||||||
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
const { user, isAuthenticated, authSource, isAdmin, logout } = useUnifiedAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<v-card class="mb-6">
|
<v-card class="mb-6">
|
||||||
<v-card-text class="pa-6">
|
<v-card-text class="pa-6">
|
||||||
<v-row align="center" class="mb-0">
|
<v-row align="center" class="mb-0">
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="filters.startDate"
|
v-model="filters.startDate"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details
|
hide-details
|
||||||
@change="fetchExpenses"
|
class="date-input-fix"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="filters.endDate"
|
v-model="filters.endDate"
|
||||||
type="date"
|
type="date"
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
variant="outlined"
|
variant="outlined"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details
|
hide-details
|
||||||
@change="fetchExpenses"
|
class="date-input-fix"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-select
|
<v-select
|
||||||
v-model="filters.category"
|
v-model="filters.category"
|
||||||
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
:items="['', 'Food/Drinks', 'Shop', 'Online', 'Other']"
|
||||||
@@ -57,15 +57,27 @@
|
|||||||
density="comfortable"
|
density="comfortable"
|
||||||
hide-details
|
hide-details
|
||||||
clearable
|
clearable
|
||||||
@update:model-value="fetchExpenses"
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="6" md="3">
|
<v-col cols="12" sm="6" md="2">
|
||||||
|
<v-btn
|
||||||
|
@click="fetchExpenses"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
size="large"
|
||||||
|
class="w-100"
|
||||||
|
prepend-icon="mdi-magnify"
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col cols="12" sm="6" md="2">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="resetToCurrentMonth"
|
@click="resetToCurrentMonth"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="large"
|
size="default"
|
||||||
class="w-100"
|
class="w-100"
|
||||||
>
|
>
|
||||||
Current Month
|
Current Month
|
||||||
@@ -187,7 +199,7 @@
|
|||||||
<div class="d-flex flex-wrap align-center">
|
<div class="d-flex flex-wrap align-center">
|
||||||
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
|
<span class="text-subtitle-1 font-weight-medium mr-6">Export Options:</span>
|
||||||
|
|
||||||
<div class="d-flex gap-4">
|
<div class="d-flex ga-4">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="exportCSV"
|
@click="exportCSV"
|
||||||
:disabled="selectedExpenses.length === 0"
|
:disabled="selectedExpenses.length === 0"
|
||||||
@@ -291,6 +303,36 @@
|
|||||||
v-model="showCreateModal"
|
v-model="showCreateModal"
|
||||||
@created="handleExpenseCreated"
|
@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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -306,8 +348,9 @@ const ExpenseCreateModal = defineAsyncComponent(() => import('@/components/Expen
|
|||||||
|
|
||||||
// Page meta
|
// Page meta
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ['authentication'],
|
middleware: ['authentication', 'authorization'],
|
||||||
layout: 'dashboard'
|
layout: 'dashboard-unified',
|
||||||
|
roles: ['sales', 'admin']
|
||||||
});
|
});
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
@@ -324,6 +367,7 @@ const showDetailsModal = ref(false);
|
|||||||
const showCreateModal = ref(false);
|
const showCreateModal = ref(false);
|
||||||
const selectedExpense = ref<Expense | null>(null);
|
const selectedExpense = ref<Expense | null>(null);
|
||||||
const activeTab = ref<string>('');
|
const activeTab = ref<string>('');
|
||||||
|
const generatingPDF = ref(false);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const filters = ref({
|
const filters = ref({
|
||||||
@@ -413,7 +457,17 @@ const fetchExpenses = async () => {
|
|||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[expenses] Error fetching expenses:', err);
|
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 {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
@@ -484,6 +538,9 @@ const exportCSV = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generatePDF = async (options: any) => {
|
const generatePDF = async (options: any) => {
|
||||||
|
generatingPDF.value = true;
|
||||||
|
showPDFModal.value = false; // Close the modal immediately
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log('[expenses] Generating PDF with options:', options);
|
console.log('[expenses] Generating PDF with options:', options);
|
||||||
|
|
||||||
@@ -504,30 +561,33 @@ const generatePDF = async (options: any) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
// For now, create HTML file instead of PDF since we're generating HTML content
|
// Decode base64 PDF content
|
||||||
const htmlContent = atob(response.data.content); // Decode base64
|
const pdfContent = atob(response.data.content);
|
||||||
const blob = new Blob([htmlContent], { type: 'text/html' });
|
|
||||||
|
// 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 url = window.URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `${options.documentName || 'expenses'}.html`;
|
a.download = response.data.filename;
|
||||||
a.click();
|
a.click();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
|
|
||||||
// Also open in new tab for immediate viewing
|
console.log('[expenses] PDF downloaded successfully:', response.data.filename);
|
||||||
const newTab = window.open();
|
|
||||||
if (newTab) {
|
|
||||||
newTab.document.open();
|
|
||||||
newTab.document.write(htmlContent);
|
|
||||||
newTab.document.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showPDFModal.value = false;
|
|
||||||
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('[expenses] Error generating PDF:', err);
|
console.error('[expenses] Error generating PDF:', err);
|
||||||
error.value = err.message || 'Failed to generate PDF';
|
error.value = err.message || 'Failed to generate PDF';
|
||||||
|
} finally {
|
||||||
|
generatingPDF.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -602,4 +662,10 @@ onMounted(async () => {
|
|||||||
.v-tab {
|
.v-tab {
|
||||||
text-transform: none !important;
|
text-transform: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Fix for date input calendar button positioning */
|
||||||
|
.date-input-fix :deep(.v-field__append-inner) {
|
||||||
|
padding-inline-start: 8px;
|
||||||
|
margin-inline-end: 4px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -336,6 +336,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'dashboard-unified'
|
||||||
|
});
|
||||||
|
|
||||||
import { ref, computed, onMounted, watch } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import FileUploader from '~/components/FileUploader.vue';
|
import FileUploader from '~/components/FileUploader.vue';
|
||||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||||
|
|||||||
@@ -116,12 +116,12 @@
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
|
<v-card-text class="pa-4" style="max-height: 600px; overflow-y: auto;">
|
||||||
<div class="d-flex flex-column gap-6">
|
<div class="d-flex flex-column">
|
||||||
<v-card
|
<v-card
|
||||||
v-for="berth in getBerthsByStatus(status.value)"
|
v-for="berth in getBerthsByStatus(status.value)"
|
||||||
:key="berth.Id"
|
:key="berth.Id"
|
||||||
@click="handleBerthClick(berth)"
|
@click="handleBerthClick(berth)"
|
||||||
class="berth-kanban-card"
|
class="berth-kanban-card mb-4"
|
||||||
:color="status.color"
|
:color="status.color"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
elevation="0"
|
elevation="0"
|
||||||
@@ -137,14 +137,24 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-space-between align-center">
|
<div class="d-flex justify-space-between align-center">
|
||||||
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
<span class="text-body-2 font-weight-medium">${{ formatPrice(berth.Price) }}</span>
|
||||||
<v-chip
|
<v-tooltip v-if="getInterestedCount(berth)" location="top">
|
||||||
v-if="getInterestedCount(berth)"
|
<template v-slot:activator="{ props }">
|
||||||
size="x-small"
|
<v-chip
|
||||||
color="primary"
|
v-bind="props"
|
||||||
variant="flat"
|
size="x-small"
|
||||||
>
|
color="primary"
|
||||||
{{ getInterestedCount(berth) }} interested
|
variant="flat"
|
||||||
</v-chip>
|
>
|
||||||
|
{{ 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>
|
</div>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</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 refreshTimer: NodeJS.Timeout | null = null
|
||||||
let isRefreshing = false
|
let isRefreshing = false
|
||||||
|
let retryCount = 0
|
||||||
|
const maxRetries = 3
|
||||||
|
|
||||||
const scheduleTokenRefresh = (expiresAt: number) => {
|
const scheduleTokenRefresh = (expiresAt: number) => {
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
@@ -12,11 +14,13 @@ export default defineNuxtPlugin(() => {
|
|||||||
refreshTimer = null
|
refreshTimer = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time until refresh (refresh 2 minutes before expiry)
|
// Calculate time until refresh (refresh 5 minutes before expiry)
|
||||||
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
|
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
|
||||||
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
|
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
|
||||||
|
|
||||||
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
|
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
|
// Only schedule if we have time left
|
||||||
if (timeUntilRefresh > 0) {
|
if (timeUntilRefresh > 0) {
|
||||||
@@ -28,20 +32,37 @@ export default defineNuxtPlugin(() => {
|
|||||||
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/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) {
|
if (response.success && response.expiresAt) {
|
||||||
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
|
||||||
|
retryCount = 0 // Reset retry count on success
|
||||||
scheduleTokenRefresh(response.expiresAt)
|
scheduleTokenRefresh(response.expiresAt)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[AUTH_REFRESH] Token refresh error:', error)
|
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 {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
@@ -56,11 +77,14 @@ export default defineNuxtPlugin(() => {
|
|||||||
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/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) {
|
if (response.success && response.expiresAt) {
|
||||||
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
console.log('[AUTH_REFRESH] Immediate refresh successful')
|
||||||
|
retryCount = 0 // Reset retry count on success
|
||||||
scheduleTokenRefresh(response.expiresAt)
|
scheduleTokenRefresh(response.expiresAt)
|
||||||
} else {
|
} else {
|
||||||
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
|
||||||
@@ -68,7 +92,19 @@ export default defineNuxtPlugin(() => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AUTH_REFRESH] Immediate refresh error:', 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 {
|
} finally {
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
}
|
}
|
||||||
@@ -127,19 +163,117 @@ export default defineNuxtPlugin(() => {
|
|||||||
|
|
||||||
// Listen for visibility changes to refresh when tab becomes active
|
// Listen for visibility changes to refresh when tab becomes active
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.addEventListener('visibilitychange', () => {
|
let lastVisibilityChange = Date.now()
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', async () => {
|
||||||
if (!document.hidden) {
|
if (!document.hidden) {
|
||||||
// Tab became visible, check if we need to refresh
|
const now = Date.now()
|
||||||
checkAndScheduleRefresh()
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
if (refreshTimer) {
|
if (refreshTimer) {
|
||||||
clearTimeout(refreshTimer)
|
clearTimeout(refreshTimer)
|
||||||
refreshTimer = null
|
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 { requireAuth, requireSalesOrAdmin } from '~/server/utils/auth';
|
||||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||||
|
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[DUPLICATES] Find duplicates request');
|
console.log('[ADMIN] Find duplicates request');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Require sales or admin access for duplicate detection
|
// Require sales or admin access for duplicate detection
|
||||||
@@ -26,17 +27,27 @@ export default defineEventHandler(async (event) => {
|
|||||||
const interests = response.list || [];
|
const interests = response.list || [];
|
||||||
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
|
console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates');
|
||||||
|
|
||||||
// Find potential duplicates
|
// Find duplicate groups using the new centralized utility
|
||||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
const duplicateConfig = createInterestConfig();
|
||||||
|
const duplicateGroups = findDuplicates(interests, duplicateConfig);
|
||||||
|
|
||||||
console.log('[ADMIN] Found', duplicateGroups.length, 'duplicate groups');
|
// Convert to the expected format
|
||||||
|
const formattedGroups = duplicateGroups.map(group => ({
|
||||||
|
id: group.id,
|
||||||
|
interests: group.items,
|
||||||
|
matchReason: group.matchReason,
|
||||||
|
confidence: group.confidence,
|
||||||
|
masterCandidate: group.masterCandidate
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('[ADMIN] Found', formattedGroups.length, 'duplicate groups');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
duplicateGroups,
|
duplicateGroups: formattedGroups,
|
||||||
totalInterests: interests.length,
|
totalInterests: interests.length,
|
||||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||||
threshold
|
threshold
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -57,203 +68,3 @@ export default defineEventHandler(async (event) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Find duplicate interests based on multiple criteria
|
|
||||||
*/
|
|
||||||
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
|
|
||||||
const duplicateGroups: Array<{
|
|
||||||
id: string;
|
|
||||||
interests: any[];
|
|
||||||
matchReason: string;
|
|
||||||
confidence: number;
|
|
||||||
masterCandidate: any;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
const processedIds = new Set<number>();
|
|
||||||
|
|
||||||
for (let i = 0; i < interests.length; i++) {
|
|
||||||
const interest1 = interests[i];
|
|
||||||
|
|
||||||
if (processedIds.has(interest1.Id)) continue;
|
|
||||||
|
|
||||||
const matches = [interest1];
|
|
||||||
|
|
||||||
for (let j = i + 1; j < interests.length; j++) {
|
|
||||||
const interest2 = interests[j];
|
|
||||||
|
|
||||||
if (processedIds.has(interest2.Id)) continue;
|
|
||||||
|
|
||||||
const similarity = calculateSimilarity(interest1, interest2);
|
|
||||||
|
|
||||||
if (similarity.score >= threshold) {
|
|
||||||
matches.push(interest2);
|
|
||||||
processedIds.add(interest2.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (matches.length > 1) {
|
|
||||||
// Mark all as processed
|
|
||||||
matches.forEach(match => processedIds.add(match.Id));
|
|
||||||
|
|
||||||
// Determine the best master candidate (most complete record)
|
|
||||||
const masterCandidate = selectMasterCandidate(matches);
|
|
||||||
|
|
||||||
duplicateGroups.push({
|
|
||||||
id: `group_${duplicateGroups.length + 1}`,
|
|
||||||
interests: matches,
|
|
||||||
matchReason: 'Multiple matching criteria',
|
|
||||||
confidence: Math.max(...matches.slice(1).map(match =>
|
|
||||||
calculateSimilarity(masterCandidate, match).score
|
|
||||||
)),
|
|
||||||
masterCandidate
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return duplicateGroups;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate similarity between two interests
|
|
||||||
*/
|
|
||||||
function calculateSimilarity(interest1: any, interest2: any) {
|
|
||||||
const scores: Array<{ type: string; score: number; weight: number }> = [];
|
|
||||||
|
|
||||||
// Email similarity (highest weight)
|
|
||||||
if (interest1['Email Address'] && interest2['Email Address']) {
|
|
||||||
const emailScore = interest1['Email Address'].toLowerCase() === interest2['Email Address'].toLowerCase() ? 1.0 : 0.0;
|
|
||||||
scores.push({ type: 'email', score: emailScore, weight: 0.4 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phone similarity
|
|
||||||
if (interest1['Phone Number'] && interest2['Phone Number']) {
|
|
||||||
const phone1 = normalizePhone(interest1['Phone Number']);
|
|
||||||
const phone2 = normalizePhone(interest2['Phone Number']);
|
|
||||||
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
|
|
||||||
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Name similarity
|
|
||||||
if (interest1['Full Name'] && interest2['Full Name']) {
|
|
||||||
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
|
|
||||||
scores.push({ type: 'name', score: nameScore, weight: 0.2 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Address similarity
|
|
||||||
if (interest1.Address && interest2.Address) {
|
|
||||||
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
|
|
||||||
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate weighted average
|
|
||||||
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
|
|
||||||
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
score: weightedScore,
|
|
||||||
details: scores
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize phone number for comparison
|
|
||||||
*/
|
|
||||||
function normalizePhone(phone: string): string {
|
|
||||||
return phone.replace(/\D/g, ''); // Remove all non-digits
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate name similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateNameSimilarity(name1: string, name2: string): number {
|
|
||||||
const str1 = name1.toLowerCase().trim();
|
|
||||||
const str2 = name2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (str1 === str2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(str1, str2);
|
|
||||||
const maxLength = Math.max(str1.length, str2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate string similarity using Levenshtein distance
|
|
||||||
*/
|
|
||||||
function calculateStringSimilarity(str1: string, str2: string): number {
|
|
||||||
const s1 = str1.toLowerCase().trim();
|
|
||||||
const s2 = str2.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (s1 === s2) return 1.0;
|
|
||||||
|
|
||||||
const distance = levenshteinDistance(s1, s2);
|
|
||||||
const maxLength = Math.max(s1.length, s2.length);
|
|
||||||
|
|
||||||
return maxLength > 0 ? 1 - (distance / maxLength) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate Levenshtein distance between two strings
|
|
||||||
*/
|
|
||||||
function levenshteinDistance(str1: string, str2: string): number {
|
|
||||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
|
||||||
|
|
||||||
for (let i = 0; i <= str1.length; i += 1) {
|
|
||||||
matrix[0][i] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 0; j <= str2.length; j += 1) {
|
|
||||||
matrix[j][0] = j;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let j = 1; j <= str2.length; j += 1) {
|
|
||||||
for (let i = 1; i <= str1.length; i += 1) {
|
|
||||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
|
||||||
matrix[j][i] = Math.min(
|
|
||||||
matrix[j][i - 1] + 1, // deletion
|
|
||||||
matrix[j - 1][i] + 1, // insertion
|
|
||||||
matrix[j - 1][i - 1] + indicator // substitution
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return matrix[str2.length][str1.length];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select the best master candidate from a group of duplicates
|
|
||||||
*/
|
|
||||||
function selectMasterCandidate(interests: any[]) {
|
|
||||||
return interests.reduce((best, current) => {
|
|
||||||
const bestScore = calculateCompletenessScore(best);
|
|
||||||
const currentScore = calculateCompletenessScore(current);
|
|
||||||
|
|
||||||
return currentScore > bestScore ? current : best;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate completeness score for an interest record
|
|
||||||
*/
|
|
||||||
function calculateCompletenessScore(interest: any): number {
|
|
||||||
const fields = ['Full Name', 'Email Address', 'Phone Number', 'Address', 'Extra Comments', 'Berth Size Desired'];
|
|
||||||
const filledFields = fields.filter(field =>
|
|
||||||
interest[field] && interest[field].toString().trim().length > 0
|
|
||||||
);
|
|
||||||
|
|
||||||
let score = filledFields.length / fields.length;
|
|
||||||
|
|
||||||
// Bonus for recent creation
|
|
||||||
if (interest['Created At']) {
|
|
||||||
const created = new Date(interest['Created At']);
|
|
||||||
const now = new Date();
|
|
||||||
const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24);
|
|
||||||
|
|
||||||
// More recent records get a small bonus
|
|
||||||
if (daysOld < 30) score += 0.1;
|
|
||||||
else if (daysOld < 90) score += 0.05;
|
|
||||||
}
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -100,8 +100,61 @@ export default defineEventHandler(async (event) => {
|
|||||||
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
|
console.log(`[KEYCLOAK] Authentication completed successfully in ${totalDuration}ms`)
|
||||||
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
|
console.log('[KEYCLOAK] Session cookie set, redirecting to dashboard...')
|
||||||
|
|
||||||
// Redirect to dashboard
|
// Return HTML with client-side redirect for SPA compatibility
|
||||||
await sendRedirect(event, '/dashboard')
|
setHeader(event, 'Content-Type', 'text/html')
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication Successful - Port Nimara Portal</title>
|
||||||
|
<meta http-equiv="refresh" content="0;url=/dashboard">
|
||||||
|
<script>
|
||||||
|
// Immediate redirect
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #387bca 0%, #2c5aa0 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h2>Authentication successful!</h2>
|
||||||
|
<p>Redirecting to dashboard...</p>
|
||||||
|
<p><small>If you are not redirected automatically, <a href="/dashboard" style="color: #ffffff;">click here</a>.</small></p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const duration = Date.now() - startTime
|
const duration = Date.now() - startTime
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { keycloakClient } from '~/server/utils/keycloak-client'
|
|||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
console.log('[REFRESH] Processing token refresh request')
|
const requestId = Math.random().toString(36).substring(7)
|
||||||
|
console.log(`[REFRESH:${requestId}] Processing token refresh request`)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get current session
|
// Get current session
|
||||||
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
const oidcSession = getCookie(event, 'nuxt-oidc-auth')
|
||||||
|
|
||||||
if (!oidcSession) {
|
if (!oidcSession) {
|
||||||
console.error('[REFRESH] No session found')
|
console.error(`[REFRESH:${requestId}] No session found`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'No session found'
|
statusMessage: 'No session found'
|
||||||
@@ -20,7 +21,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
try {
|
try {
|
||||||
sessionData = JSON.parse(oidcSession)
|
sessionData = JSON.parse(oidcSession)
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[REFRESH] Failed to parse session:', parseError)
|
console.error(`[REFRESH:${requestId}] Failed to parse session:`, parseError)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'Invalid session format'
|
statusMessage: 'Invalid session format'
|
||||||
@@ -29,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
// Check if we have a refresh token
|
// Check if we have a refresh token
|
||||||
if (!sessionData.refreshToken) {
|
if (!sessionData.refreshToken) {
|
||||||
console.error('[REFRESH] No refresh token available')
|
console.error(`[REFRESH:${requestId}] No refresh token available`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
statusMessage: 'No refresh token available'
|
statusMessage: 'No refresh token available'
|
||||||
@@ -39,24 +40,48 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Validate environment variables
|
// Validate environment variables
|
||||||
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
const clientSecret = process.env.KEYCLOAK_CLIENT_SECRET
|
||||||
if (!clientSecret) {
|
if (!clientSecret) {
|
||||||
console.error('[REFRESH] KEYCLOAK_CLIENT_SECRET not configured')
|
console.error(`[REFRESH:${requestId}] KEYCLOAK_CLIENT_SECRET not configured`)
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Authentication service misconfigured'
|
statusMessage: 'Authentication service misconfigured'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use refresh token to get new access token with retry logic
|
// Use refresh token to get new access token with enhanced error handling
|
||||||
console.log('[REFRESH] Using Keycloak client for token refresh...')
|
console.log(`[REFRESH:${requestId}] Using Keycloak client for token refresh...`)
|
||||||
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
const tokenResponse = await keycloakClient.refreshAccessToken(sessionData.refreshToken)
|
||||||
|
.catch((error: any) => {
|
||||||
|
// Check if it's a transient error
|
||||||
|
if (error.statusMessage === 'KEYCLOAK_TEMPORARILY_UNAVAILABLE') {
|
||||||
|
console.log(`[REFRESH:${requestId}] Keycloak temporarily unavailable, using grace period`)
|
||||||
|
// Return current session with extended grace period
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
expiresAt: sessionData.expiresAt,
|
||||||
|
gracePeriod: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error // Re-throw for permanent failures
|
||||||
|
})
|
||||||
|
|
||||||
const refreshDuration = Date.now() - startTime
|
const refreshDuration = Date.now() - startTime
|
||||||
console.log(`[REFRESH] Token refresh successful in ${refreshDuration}ms:`, {
|
console.log(`[REFRESH:${requestId}] Token refresh successful in ${refreshDuration}ms:`, {
|
||||||
hasAccessToken: !!tokenResponse.access_token,
|
hasAccessToken: !!tokenResponse.access_token,
|
||||||
hasRefreshToken: !!tokenResponse.refresh_token,
|
hasRefreshToken: !!tokenResponse.refresh_token,
|
||||||
expiresIn: tokenResponse.expires_in
|
expiresIn: tokenResponse.expires_in,
|
||||||
|
gracePeriod: tokenResponse.gracePeriod
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Handle grace period response
|
||||||
|
if (tokenResponse.gracePeriod) {
|
||||||
|
console.log(`[REFRESH:${requestId}] Using grace period - session extended`)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
expiresAt: tokenResponse.expiresAt,
|
||||||
|
gracePeriod: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update session with new tokens
|
// Update session with new tokens
|
||||||
const updatedSessionData = {
|
const updatedSessionData = {
|
||||||
...sessionData,
|
...sessionData,
|
||||||
@@ -79,7 +104,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log('[REFRESH] Session updated successfully')
|
console.log(`[REFRESH:${requestId}] Session updated successfully`)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -87,14 +112,17 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[REFRESH] Token refresh failed:', error)
|
console.error(`[REFRESH:${requestId}] Token refresh failed:`, error)
|
||||||
|
|
||||||
// Clear invalid session
|
// Only clear session for permanent failures
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
if (error.statusMessage === 'REFRESH_TOKEN_INVALID') {
|
||||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
console.log(`[REFRESH:${requestId}] Clearing session due to invalid refresh token`)
|
||||||
domain: cookieDomain,
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||||
path: '/'
|
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||||
})
|
domain: cookieDomain,
|
||||||
|
path: '/'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[SESSION] Checking authentication session...')
|
const requestId = Math.random().toString(36).substring(7)
|
||||||
|
const startTime = Date.now()
|
||||||
|
console.log(`[SESSION:${requestId}] Checking authentication session...`)
|
||||||
|
|
||||||
// Check OIDC/Keycloak authentication only
|
// Check OIDC/Keycloak authentication only
|
||||||
try {
|
try {
|
||||||
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
const oidcSessionCookie = getCookie(event, 'nuxt-oidc-auth')
|
||||||
|
|
||||||
if (!oidcSessionCookie) {
|
if (!oidcSessionCookie) {
|
||||||
console.log('[SESSION] No OIDC session cookie found')
|
console.log(`[SESSION:${requestId}] No OIDC session cookie found`)
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'NO_SESSION_COOKIE',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[SESSION] OIDC session cookie found, parsing...')
|
console.log(`[SESSION:${requestId}] OIDC session cookie found, parsing...`)
|
||||||
|
|
||||||
let sessionData
|
let sessionData
|
||||||
try {
|
try {
|
||||||
// Parse the session data
|
// Parse the session data
|
||||||
|
const parseStart = Date.now()
|
||||||
sessionData = JSON.parse(oidcSessionCookie)
|
sessionData = JSON.parse(oidcSessionCookie)
|
||||||
console.log('[SESSION] Session data parsed successfully:', {
|
const parseTime = Date.now() - parseStart
|
||||||
|
|
||||||
|
console.log(`[SESSION:${requestId}] Session data parsed successfully in ${parseTime}ms:`, {
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
hasAccessToken: !!sessionData.accessToken,
|
hasAccessToken: !!sessionData.accessToken,
|
||||||
hasIdToken: !!sessionData.idToken,
|
hasIdToken: !!sessionData.idToken,
|
||||||
@@ -25,19 +36,25 @@ export default defineEventHandler(async (event) => {
|
|||||||
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
timeUntilExpiry: sessionData.expiresAt ? sessionData.expiresAt - Date.now() : 'unknown'
|
||||||
})
|
})
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[SESSION] Failed to parse session cookie:', parseError)
|
console.error(`[SESSION:${requestId}] Failed to parse session cookie:`, parseError)
|
||||||
// Clear invalid session
|
// Clear invalid session
|
||||||
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
const cookieDomain = process.env.COOKIE_DOMAIN || '.portnimara.dev';
|
||||||
deleteCookie(event, 'nuxt-oidc-auth', {
|
deleteCookie(event, 'nuxt-oidc-auth', {
|
||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'INVALID_SESSION_FORMAT',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate session structure
|
// Validate session structure
|
||||||
if (!sessionData.user || !sessionData.accessToken) {
|
if (!sessionData.user || !sessionData.accessToken) {
|
||||||
console.error('[SESSION] Invalid session structure:', {
|
console.error(`[SESSION:${requestId}] Invalid session structure:`, {
|
||||||
hasUser: !!sessionData.user,
|
hasUser: !!sessionData.user,
|
||||||
hasAccessToken: !!sessionData.accessToken
|
hasAccessToken: !!sessionData.accessToken
|
||||||
})
|
})
|
||||||
@@ -46,12 +63,18 @@ export default defineEventHandler(async (event) => {
|
|||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'INVALID_SESSION_STRUCTURE',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if session is still valid
|
// Check if session is still valid
|
||||||
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
if (sessionData.expiresAt && Date.now() > sessionData.expiresAt) {
|
||||||
console.log('[SESSION] Session expired:', {
|
console.log(`[SESSION:${requestId}] Session expired:`, {
|
||||||
expiresAt: sessionData.expiresAt,
|
expiresAt: sessionData.expiresAt,
|
||||||
currentTime: Date.now(),
|
currentTime: Date.now(),
|
||||||
expiredSince: Date.now() - sessionData.expiresAt
|
expiredSince: Date.now() - sessionData.expiresAt
|
||||||
@@ -62,7 +85,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
domain: cookieDomain,
|
domain: cookieDomain,
|
||||||
path: '/'
|
path: '/'
|
||||||
})
|
})
|
||||||
return { user: null, authenticated: false, groups: [] }
|
return {
|
||||||
|
user: null,
|
||||||
|
authenticated: false,
|
||||||
|
groups: [],
|
||||||
|
reason: 'SESSION_EXPIRED',
|
||||||
|
requestId
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract groups from ID token
|
// Extract groups from ID token
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
// Documenso API configuration - moved to top for use throughout
|
// Documenso API configuration - moved to top for use throughout
|
||||||
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
|
||||||
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
|
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) {
|
if (!documensoApiKey || !documensoBaseUrl) {
|
||||||
throw createError({
|
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`,
|
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",
|
subject: "Your LOI is ready to be signed",
|
||||||
redirectUrl: "https://portnimara.com",
|
redirectUrl: "https://portnimara.com",
|
||||||
distributionMethod: "SEQUENTIAL"
|
distributionMethod: "NONE"
|
||||||
},
|
},
|
||||||
title: `${interest['Full Name']}-EOI-NDA`,
|
title: `${interest['Full Name']}-EOI-NDA`,
|
||||||
externalId: `loi-${interestId}`,
|
externalId: `loi-${interestId}`,
|
||||||
@@ -249,22 +252,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
},
|
},
|
||||||
recipients: [
|
recipients: [
|
||||||
{
|
{
|
||||||
id: 155,
|
id: clientRecipientId,
|
||||||
name: interest['Full Name'],
|
name: interest['Full Name'],
|
||||||
role: "SIGNER",
|
role: "SIGNER",
|
||||||
email: interest['Email Address'],
|
email: interest['Email Address'],
|
||||||
signingOrder: 1
|
signingOrder: 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 156,
|
id: davidRecipientId,
|
||||||
name: "David Mizrahi",
|
name: "David Mizrahi",
|
||||||
role: "SIGNER",
|
role: "SIGNER",
|
||||||
email: "dm@portnimara.com",
|
email: "dm@portnimara.com",
|
||||||
signingOrder: 3
|
signingOrder: 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 157,
|
id: approvalRecipientId,
|
||||||
name: "Oscar Faragher",
|
name: "Approval",
|
||||||
role: "APPROVER",
|
role: "APPROVER",
|
||||||
email: "sales@portnimara.com",
|
email: "sales@portnimara.com",
|
||||||
signingOrder: 2
|
signingOrder: 2
|
||||||
@@ -337,7 +340,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
} else if (recipient.email === 'dm@portnimara.com') {
|
} else if (recipient.email === 'dm@portnimara.com') {
|
||||||
signingLinks['David Mizrahi'] = recipient.signingUrl;
|
signingLinks['David Mizrahi'] = recipient.signingUrl;
|
||||||
} else if (recipient.email === 'sales@portnimara.com') {
|
} 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;
|
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
|
||||||
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
|
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
|
||||||
}
|
}
|
||||||
if (signingLinks['Oscar Faragher']) {
|
if (signingLinks['Approval']) {
|
||||||
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
|
updateData['Signature Link CC'] = signingLinks['Approval'];
|
||||||
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
|
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc');
|
||||||
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
|
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);
|
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ export default defineEventHandler(async (event) => {
|
|||||||
await requireAuth(event);
|
await requireAuth(event);
|
||||||
|
|
||||||
console.log('[Delete Generated EOI] Request received');
|
console.log('[Delete Generated EOI] Request received');
|
||||||
|
console.log('[Delete Generated EOI] Request headers:', getHeaders(event));
|
||||||
|
console.log('[Delete Generated EOI] Request method:', getMethod(event));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { interestId } = body;
|
const { interestId } = body;
|
||||||
|
const query = getQuery(event);
|
||||||
|
|
||||||
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
console.log('[Delete Generated EOI] Interest ID:', interestId);
|
||||||
|
console.log('[Delete Generated EOI] Query params:', query);
|
||||||
|
|
||||||
if (!interestId) {
|
if (!interestId) {
|
||||||
console.error('[Delete Generated EOI] No interest ID provided');
|
console.error('[Delete Generated EOI] No interest ID provided');
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
statusMessage: 'Interest ID is required',
|
statusMessage: 'Interest ID is required. Please provide a valid interest ID.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,51 +81,132 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
console.log('[Delete Generated EOI] Deleting document from Documenso');
|
console.log('[Delete Generated EOI] Deleting document from Documenso');
|
||||||
let documensoDeleteSuccessful = false;
|
let documensoDeleteSuccessful = false;
|
||||||
|
let retryCount = 0;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
try {
|
// Retry logic for temporary failures
|
||||||
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
|
while (!documensoDeleteSuccessful && retryCount < maxRetries) {
|
||||||
method: 'DELETE',
|
try {
|
||||||
headers: {
|
const deleteResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documensoID}`, {
|
||||||
'Authorization': `Bearer ${documensoApiKey}`,
|
method: 'DELETE',
|
||||||
'Content-Type': 'application/json'
|
headers: {
|
||||||
|
'Authorization': `Bearer ${documensoApiKey}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseStatus = deleteResponse.status;
|
||||||
|
let errorDetails = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
errorDetails = await deleteResponse.text();
|
||||||
|
} catch {
|
||||||
|
errorDetails = 'No error details available';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!deleteResponse.ok) {
|
if (!deleteResponse.ok) {
|
||||||
const errorText = await deleteResponse.text();
|
console.error(`[Delete Generated EOI] Documenso deletion failed (attempt ${retryCount + 1}/${maxRetries}):`, {
|
||||||
console.error('[Delete Generated EOI] Documenso deletion failed:', errorText);
|
status: responseStatus,
|
||||||
|
statusText: deleteResponse.statusText,
|
||||||
|
details: errorDetails
|
||||||
|
});
|
||||||
|
|
||||||
// If it's a 404, the document is already gone, which is what we want
|
// Handle specific status codes
|
||||||
if (deleteResponse.status === 404) {
|
switch (responseStatus) {
|
||||||
console.log('[Delete Generated EOI] Document already deleted from Documenso (404) - proceeding with database cleanup');
|
case 404:
|
||||||
documensoDeleteSuccessful = true;
|
// 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 {
|
} else {
|
||||||
throw new Error(`Failed to delete document from Documenso: ${deleteResponse.statusText}`);
|
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;
|
||||||
}
|
}
|
||||||
} 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 {
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: `Failed to delete document from Documenso: ${error.message}`,
|
statusMessage: error.message || 'Failed to communicate with Documenso API',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!documensoDeleteSuccessful) {
|
if (!documensoDeleteSuccessful) {
|
||||||
throw createError({
|
const query = getQuery(event);
|
||||||
statusCode: 500,
|
if (query.forceCleanup === 'true') {
|
||||||
statusMessage: 'Failed to delete document from Documenso',
|
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
|
// Reset interest fields
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
import { requireSalesOrAdmin } from '~/server/utils/auth';
|
||||||
import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
|
import { getNocoDbConfiguration, normalizePersonName } from '~/server/utils/nocodb';
|
||||||
|
import { findDuplicates, createExpenseConfig } from '~/server/utils/duplicate-detection';
|
||||||
import type { Expense } from '~/utils/types';
|
import type { Expense } from '~/utils/types';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
@@ -35,21 +36,31 @@ export default defineEventHandler(async (event) => {
|
|||||||
const expenses = response.list || [];
|
const expenses = response.list || [];
|
||||||
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
|
console.log('[EXPENSES] Analyzing', expenses.length, 'expenses for duplicates');
|
||||||
|
|
||||||
// Find duplicate groups
|
// Find duplicate groups using the new centralized utility
|
||||||
const duplicateGroups = findDuplicateExpenses(expenses);
|
const duplicateConfig = createExpenseConfig();
|
||||||
|
const duplicateGroups = findDuplicates(expenses, duplicateConfig);
|
||||||
|
|
||||||
|
// Convert to the expected format
|
||||||
|
const formattedGroups = duplicateGroups.map(group => ({
|
||||||
|
id: group.id,
|
||||||
|
expenses: group.items,
|
||||||
|
matchReason: group.matchReason,
|
||||||
|
confidence: group.confidence,
|
||||||
|
masterCandidate: group.masterCandidate
|
||||||
|
}));
|
||||||
|
|
||||||
// Also find payer name variations
|
// Also find payer name variations
|
||||||
const payerVariations = findPayerNameVariations(expenses);
|
const payerVariations = findPayerNameVariations(expenses);
|
||||||
|
|
||||||
console.log('[EXPENSES] Found', duplicateGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations');
|
console.log('[EXPENSES] Found', formattedGroups.length, 'duplicate groups and', payerVariations.length, 'payer variations');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
duplicateGroups,
|
duplicateGroups: formattedGroups,
|
||||||
payerVariations,
|
payerVariations,
|
||||||
totalExpenses: expenses.length,
|
totalExpenses: expenses.length,
|
||||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.expenses.length, 0),
|
||||||
dateRange: {
|
dateRange: {
|
||||||
start: startDate.toISOString().split('T')[0],
|
start: startDate.toISOString().split('T')[0],
|
||||||
end: endDate.toISOString().split('T')[0]
|
end: endDate.toISOString().split('T')[0]
|
||||||
@@ -74,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")
|
* 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);
|
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 { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||||
import { createError } from 'h3';
|
import { createError } from 'h3';
|
||||||
import { formatDate } from '@/utils/dateUtils';
|
import { formatDate } from '@/utils/dateUtils';
|
||||||
|
import PDFDocument from 'pdfkit';
|
||||||
|
import { getMinioClient } from '@/server/utils/minio';
|
||||||
|
|
||||||
interface PDFOptions {
|
interface PDFOptions {
|
||||||
documentName: string;
|
documentName: string;
|
||||||
subheader?: string;
|
subheader?: string;
|
||||||
groupBy: 'none' | 'payer' | 'category' | 'date';
|
groupBy: 'none' | 'payer' | 'category' | 'date';
|
||||||
includeReceipts: boolean;
|
includeReceipts: boolean;
|
||||||
|
includeReceiptContents: boolean;
|
||||||
includeSummary: boolean;
|
includeSummary: boolean;
|
||||||
includeDetails: boolean;
|
includeDetails: boolean;
|
||||||
pageFormat: 'A4' | 'Letter' | 'Legal';
|
pageFormat: 'A4' | 'Letter' | 'Legal';
|
||||||
includeProcessingFee?: boolean;
|
includeProcessingFee?: boolean;
|
||||||
|
targetCurrency?: 'USD' | 'EUR';
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Expense {
|
interface Expense {
|
||||||
@@ -20,7 +24,11 @@ interface Expense {
|
|||||||
'Establishment Name': string;
|
'Establishment Name': string;
|
||||||
Price: string;
|
Price: string;
|
||||||
PriceNumber: number;
|
PriceNumber: number;
|
||||||
|
Currency?: string;
|
||||||
|
CurrencySymbol?: string;
|
||||||
DisplayPrice: string;
|
DisplayPrice: string;
|
||||||
|
DisplayPriceWithEUR?: string;
|
||||||
|
PriceEUR?: number;
|
||||||
PriceUSD?: number;
|
PriceUSD?: number;
|
||||||
ConversionRate?: number;
|
ConversionRate?: number;
|
||||||
Payer: string;
|
Payer: string;
|
||||||
@@ -54,12 +62,13 @@ export default defineEventHandler(async (event) => {
|
|||||||
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
|
console.log('[expenses/generate-pdf] PDF generation requested for expenses:', expenseIds);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch expense data
|
// Fetch expense data with target currency processing
|
||||||
|
const targetCurrency = options.targetCurrency || 'EUR';
|
||||||
const expenses: Expense[] = [];
|
const expenses: Expense[] = [];
|
||||||
for (const expenseId of expenseIds) {
|
for (const expenseId of expenseIds) {
|
||||||
const expense = await getExpenseById(expenseId);
|
const expense = await getExpenseById(expenseId);
|
||||||
if (expense) {
|
if (expense) {
|
||||||
const processedExpense = await processExpenseWithCurrency(expense);
|
const processedExpense = await processExpenseWithCurrency(expense, targetCurrency);
|
||||||
expenses.push(processedExpense);
|
expenses.push(processedExpense);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,33 +81,28 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate totals
|
// 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] Successfully calculated totals:', totals);
|
||||||
console.log('[expenses/generate-pdf] Options received:', options);
|
console.log('[expenses/generate-pdf] Options received:', options);
|
||||||
|
|
||||||
// Generate PDF content
|
// Generate PDF using PDFKit
|
||||||
const pdfContent = generatePDFContent(expenses, options, totals);
|
const pdfBuffer = await generatePDFWithPDFKit(expenses, options, totals);
|
||||||
|
|
||||||
// Return PDF as base64 for download
|
// Return PDF as base64 for download
|
||||||
const pdfBase64 = Buffer.from(pdfContent).toString('base64');
|
const pdfBase64 = pdfBuffer.toString('base64');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
filename: `${options.documentName.replace(/[^a-zA-Z0-9]/g, '_')}.pdf`,
|
filename: `${options.documentName.replace(/[^a-zA-Z0-9\-_\s]/g, '_')}.pdf`,
|
||||||
content: pdfBase64,
|
content: pdfBase64,
|
||||||
mimeType: 'application/pdf',
|
mimeType: 'application/pdf',
|
||||||
size: pdfContent.length
|
size: pdfBuffer.length
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error: any) {
|
} 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);
|
console.error('[expenses/generate-pdf] Error generating PDF:', error);
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
@@ -107,18 +111,33 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean) {
|
function calculateTotals(expenses: Expense[], includeProcessingFee: boolean = false, targetCurrency: string = 'EUR') {
|
||||||
const originalTotal = expenses.reduce((sum, exp) => sum + (exp.PriceNumber || 0), 0);
|
// 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 usdTotal = expenses.reduce((sum, exp) => sum + (exp.PriceUSD || exp.PriceNumber || 0), 0);
|
||||||
|
|
||||||
const processingFee = includeProcessingFee ? originalTotal * 0.05 : 0;
|
// Processing fee is calculated on target currency total
|
||||||
const finalTotal = originalTotal + processingFee;
|
const processingFee = includeProcessingFee ? targetTotal * 0.05 : 0;
|
||||||
|
const finalTotal = targetTotal + processingFee;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
originalTotal,
|
targetTotal,
|
||||||
|
eurTotal,
|
||||||
usdTotal,
|
usdTotal,
|
||||||
processingFee,
|
processingFee,
|
||||||
finalTotal,
|
finalTotal,
|
||||||
|
targetCurrency: targetCurrency.toUpperCase(),
|
||||||
count: expenses.length
|
count: expenses.length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,127 +151,315 @@ function getGroupingLabel(groupBy: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function generatePDFContent(expenses: Expense[], options: PDFOptions, totals: any): string {
|
function getPageDimensions(pageFormat: string) {
|
||||||
// Generate HTML content that can be converted to PDF
|
switch (pageFormat) {
|
||||||
const html = `
|
case 'Letter':
|
||||||
<!DOCTYPE html>
|
return { width: 612, height: 792 }; // 8.5" x 11"
|
||||||
<html>
|
case 'Legal':
|
||||||
<head>
|
return { width: 612, height: 1008 }; // 8.5" x 14"
|
||||||
<meta charset="UTF-8">
|
case 'A4':
|
||||||
<title>${options.documentName}</title>
|
default:
|
||||||
<style>
|
return { width: 595, height: 842 }; // A4
|
||||||
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 generateExpenseTable(expenses: Expense[], options: PDFOptions): string {
|
async function generatePDFWithPDFKit(expenses: Expense[], options: PDFOptions, totals: any): Promise<Buffer> {
|
||||||
let tableHTML = `
|
return new Promise(async (resolve, reject) => {
|
||||||
<table class="expense-table">
|
try {
|
||||||
<thead>
|
console.log('[expenses/generate-pdf] Generating PDF with PDFKit...');
|
||||||
<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>
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (options.groupBy === 'none') {
|
const pageDimensions = getPageDimensions(options.pageFormat);
|
||||||
// No grouping - just list all expenses
|
const doc = new PDFDocument({
|
||||||
expenses.forEach(expense => {
|
size: [pageDimensions.width, pageDimensions.height],
|
||||||
tableHTML += generateExpenseRow(expense, options);
|
margins: { top: 60, bottom: 60, left: 60, right: 60 }
|
||||||
});
|
|
||||||
} 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);
|
|
||||||
|
|
||||||
// Group header
|
|
||||||
tableHTML += `
|
|
||||||
<tr class="group-header">
|
|
||||||
<td colspan="${options.includeDetails ? '7' : '6'}">${groupKey} (${groupExpenses.length} expenses - €${groupTotal.toFixed(2)})</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Group expenses
|
|
||||||
groupExpenses.forEach(expense => {
|
|
||||||
tableHTML += generateExpenseRow(expense, options);
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
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'}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
tableHTML += `
|
// Add line separator
|
||||||
</tbody>
|
const y = doc.y + 10;
|
||||||
</table>
|
doc.moveTo(60, y)
|
||||||
`;
|
.lineTo(doc.page.width - 60, y)
|
||||||
|
.strokeColor('#333333')
|
||||||
|
.lineWidth(2)
|
||||||
|
.stroke();
|
||||||
|
|
||||||
return tableHTML;
|
doc.y = y + 20;
|
||||||
|
doc.fillColor('#000000'); // Reset color
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateExpenseRow(expense: Expense, options: PDFOptions): string {
|
function addSummary(doc: PDFKit.PDFDocument, totals: any, options: PDFOptions) {
|
||||||
const date = expense.Time ? formatDate(expense.Time) : 'N/A';
|
doc.fontSize(18)
|
||||||
const description = expense.Contents || 'N/A';
|
.font('Helvetica-Bold')
|
||||||
|
.text('Summary', { continued: false });
|
||||||
|
|
||||||
return `
|
doc.y += 10;
|
||||||
<tr>
|
|
||||||
<td>${date}</td>
|
// Summary box
|
||||||
<td>${expense['Establishment Name'] || 'N/A'}</td>
|
const boxY = doc.y;
|
||||||
<td>${expense.Category || 'N/A'}</td>
|
const boxHeight = options.includeProcessingFee ? 140 : 120;
|
||||||
<td>${expense.Payer || 'N/A'}</td>
|
|
||||||
<td>€${expense.PriceNumber ? expense.PriceNumber.toFixed(2) : '0.00'}</td>
|
doc.rect(60, boxY, doc.page.width - 120, boxHeight)
|
||||||
<td>${expense['Payment Method'] || 'N/A'}</td>
|
.fillColor('#f5f5f5')
|
||||||
${options.includeDetails ? `<td>${description}</td>` : ''}
|
.fill()
|
||||||
</tr>
|
.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 {
|
||||||
|
const groups = groupExpenses(expenses, options.groupBy);
|
||||||
|
|
||||||
|
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 - 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
|
||||||
|
currentY = await drawExpenseRows(doc, groupExpenses, columns, currentY, rowHeight, fontSize, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drawExpenseRows(
|
||||||
|
doc: PDFKit.PDFDocument,
|
||||||
|
expenses: Expense[],
|
||||||
|
columns: any[],
|
||||||
|
startY: number,
|
||||||
|
rowHeight: number,
|
||||||
|
fontSize: number,
|
||||||
|
options: PDFOptions
|
||||||
|
): Promise<number> {
|
||||||
|
let currentY = startY;
|
||||||
|
|
||||||
|
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[]> {
|
function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Expense[]> {
|
||||||
@@ -281,3 +488,450 @@ function groupExpenses(expenses: Expense[], groupBy: string): Record<string, Exp
|
|||||||
|
|
||||||
return groups;
|
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 { processExpenseWithCurrency } from '@/server/utils/currency';
|
||||||
import type { ExpenseFilters } from '@/utils/types';
|
import type { ExpenseFilters } from '@/utils/types';
|
||||||
|
|
||||||
|
// Retry operation wrapper for database calls
|
||||||
|
async function retryOperation<T>(
|
||||||
|
operation: () => Promise<T>,
|
||||||
|
maxRetries: number = 3,
|
||||||
|
baseDelay: number = 1000
|
||||||
|
): Promise<T> {
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.log(`[get-expenses] Attempt ${attempt}/${maxRetries} failed:`, error.message);
|
||||||
|
|
||||||
|
// Don't retry on authentication/authorization errors
|
||||||
|
if (error.statusCode === 401 || error.statusCode === 403) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't retry on client errors (4xx except 404)
|
||||||
|
if (error.statusCode >= 400 && error.statusCode < 500 && error.statusCode !== 404) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this is the last attempt, throw the error
|
||||||
|
if (attempt === maxRetries) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For retryable errors (5xx, network errors, timeouts), wait before retry
|
||||||
|
const delay = baseDelay * Math.pow(2, attempt - 1); // Exponential backoff
|
||||||
|
console.log(`[get-expenses] Retrying in ${delay}ms...`);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Retry operation failed unexpectedly');
|
||||||
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[get-expenses] API called with query:', getQuery(event));
|
console.log('[get-expenses] API called with query:', getQuery(event));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check authentication
|
// Set proper headers
|
||||||
|
setHeader(event, 'Cache-Control', 'no-cache');
|
||||||
|
setHeader(event, 'Content-Type', 'application/json');
|
||||||
|
|
||||||
|
// Check authentication first
|
||||||
try {
|
try {
|
||||||
await requireSalesOrAdmin(event);
|
await requireSalesOrAdmin(event);
|
||||||
|
console.log('[get-expenses] Authentication successful');
|
||||||
} catch (authError: any) {
|
} catch (authError: any) {
|
||||||
console.error('[get-expenses] Authentication failed:', authError);
|
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');
|
console.log('[get-expenses] No date filters provided, defaulting to current month');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getCurrentMonthExpenses();
|
const result = await retryOperation(() => getCurrentMonthExpenses());
|
||||||
|
|
||||||
// Process expenses with currency conversion
|
// Process expenses with currency conversion
|
||||||
const processedExpenses = await Promise.all(
|
const processedExpenses = await Promise.all(
|
||||||
@@ -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({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||||
@@ -82,7 +131,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
console.log('[get-expenses] Fetching expenses with filters:', filters);
|
console.log('[get-expenses] Fetching expenses with filters:', filters);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await getExpenses(filters);
|
const result = await retryOperation(() => getExpenses(filters));
|
||||||
|
|
||||||
// Process expenses with currency conversion
|
// Process expenses with currency conversion
|
||||||
const processedExpenses = await Promise.all(
|
const processedExpenses = await Promise.all(
|
||||||
@@ -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({
|
throw createError({
|
||||||
statusCode: 500,
|
statusCode: 500,
|
||||||
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
statusMessage: 'Unable to fetch expense data. Please try again later.'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (authError: any) {
|
} catch (error: any) {
|
||||||
if (authError.statusCode === 403) {
|
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({
|
throw createError({
|
||||||
statusCode: 403,
|
statusCode: 401,
|
||||||
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
|
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 { requireSalesOrAdmin } from '~/server/utils/auth';
|
||||||
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
|
||||||
import { logAuditEvent } from '~/server/utils/audit-logger';
|
import { logAuditEvent } from '~/server/utils/audit-logger';
|
||||||
|
import { findDuplicates, createInterestConfig } from '~/server/utils/duplicate-detection';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
console.log('[INTERESTS] Find duplicates request');
|
console.log('[INTERESTS] Find duplicates request');
|
||||||
@@ -19,11 +20,12 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
let url = `${config.url}/api/v2/tables/${interestTableId}/records`;
|
let url = `${config.url}/api/v2/tables/${interestTableId}/records`;
|
||||||
|
|
||||||
// Add date filtering if specified
|
// Add date filtering if specified (include records without Created At)
|
||||||
if (dateRange && dateRange > 0) {
|
if (dateRange && dateRange > 0) {
|
||||||
const cutoffDate = new Date();
|
const cutoffDate = new Date();
|
||||||
cutoffDate.setDate(cutoffDate.getDate() - dateRange);
|
cutoffDate.setDate(cutoffDate.getDate() - dateRange);
|
||||||
const dateFilter = `(Created At,gte,${cutoffDate.toISOString()})`;
|
// Include records without Created At OR within date range
|
||||||
|
const dateFilter = `((Created At,gte,${cutoffDate.toISOString()}),or,(Created At,is,null))`;
|
||||||
url += `?where=${encodeURIComponent(dateFilter)}`;
|
url += `?where=${encodeURIComponent(dateFilter)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,16 +41,26 @@ export default defineEventHandler(async (event) => {
|
|||||||
const interests = response.list || [];
|
const interests = response.list || [];
|
||||||
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
|
console.log('[INTERESTS] Analyzing', interests.length, 'interests for duplicates');
|
||||||
|
|
||||||
// Find potential duplicates
|
// Find duplicate groups using the new centralized utility
|
||||||
const duplicateGroups = findDuplicateInterests(interests, threshold);
|
const duplicateConfig = createInterestConfig();
|
||||||
|
const duplicateGroups = findDuplicates(interests, duplicateConfig);
|
||||||
|
|
||||||
console.log('[INTERESTS] Found', duplicateGroups.length, 'duplicate groups');
|
// Convert to the expected format
|
||||||
|
const formattedGroups = duplicateGroups.map(group => ({
|
||||||
|
id: group.id,
|
||||||
|
interests: group.items,
|
||||||
|
matchReason: group.matchReason,
|
||||||
|
confidence: group.confidence,
|
||||||
|
masterCandidate: group.masterCandidate
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log('[INTERESTS] Found', formattedGroups.length, 'duplicate groups');
|
||||||
|
|
||||||
// Log the audit event
|
// Log the audit event
|
||||||
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
|
await logAuditEvent(event, 'FIND_INTEREST_DUPLICATES', 'interest', {
|
||||||
changes: {
|
changes: {
|
||||||
totalInterests: interests.length,
|
totalInterests: interests.length,
|
||||||
duplicateGroups: duplicateGroups.length,
|
duplicateGroups: formattedGroups.length,
|
||||||
threshold,
|
threshold,
|
||||||
dateRange
|
dateRange
|
||||||
}
|
}
|
||||||
@@ -57,9 +69,9 @@ export default defineEventHandler(async (event) => {
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
duplicateGroups,
|
duplicateGroups: formattedGroups,
|
||||||
totalInterests: interests.length,
|
totalInterests: interests.length,
|
||||||
duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
duplicateCount: formattedGroups.reduce((sum, group) => sum + group.interests.length, 0),
|
||||||
threshold,
|
threshold,
|
||||||
dateRange
|
dateRange
|
||||||
}
|
}
|
||||||
@@ -81,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
|
* 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
|
* 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 };
|
const processedExpense = { ...expense };
|
||||||
|
|
||||||
// Parse price number
|
// Parse price number
|
||||||
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
|
||||||
processedExpense.PriceNumber = priceNumber;
|
processedExpense.PriceNumber = priceNumber;
|
||||||
|
|
||||||
// Get currency symbol
|
// Get currency code and symbol
|
||||||
const currencyCode = expense.currency || 'USD';
|
const currencyCode = expense.currency || 'USD';
|
||||||
|
processedExpense.Currency = currencyCode;
|
||||||
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
|
||||||
|
|
||||||
// Convert to USD if not already USD
|
// Convert to target currency if not already in target
|
||||||
if (currencyCode.toUpperCase() !== 'USD') {
|
const targetCurrencyUpper = targetCurrency.toUpperCase();
|
||||||
const conversion = await convertToUSD(priceNumber, currencyCode);
|
const targetField = `Price${targetCurrencyUpper}`;
|
||||||
|
|
||||||
|
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
|
||||||
|
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
|
||||||
|
|
||||||
if (conversion) {
|
if (conversion) {
|
||||||
processedExpense.PriceUSD = conversion.usdAmount;
|
processedExpense[targetField] = conversion.targetAmount;
|
||||||
processedExpense.ConversionRate = conversion.rate;
|
processedExpense.ConversionRate = conversion.rate;
|
||||||
processedExpense.ConversionDate = conversion.conversionDate;
|
processedExpense.ConversionDate = conversion.conversionDate;
|
||||||
|
processedExpense.TargetCurrency = targetCurrencyUpper;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If already USD, set USD amount to original amount
|
// If already in target currency, set target amount to original amount
|
||||||
processedExpense.PriceUSD = priceNumber;
|
processedExpense[targetField] = priceNumber;
|
||||||
processedExpense.ConversionRate = 1.0;
|
processedExpense.ConversionRate = 1.0;
|
||||||
processedExpense.ConversionDate = new Date().toISOString();
|
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
|
// Create display prices
|
||||||
processedExpense.DisplayPrice = createDisplayPrice(
|
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
|
||||||
priceNumber,
|
|
||||||
currencyCode,
|
|
||||||
processedExpense.PriceUSD
|
|
||||||
);
|
|
||||||
|
|
||||||
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
|
// Create display price with target currency conversion
|
||||||
processedExpense.PriceUSD || priceNumber,
|
const targetAmount = processedExpense[targetField];
|
||||||
'USD'
|
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;
|
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'
|
const tokenUrl = 'https://auth.portnimara.dev/realms/client-portal/protocol/openid-connect/token'
|
||||||
|
|
||||||
return this.fetch(tokenUrl, {
|
try {
|
||||||
method: 'POST',
|
const response = await this.fetch(tokenUrl, {
|
||||||
headers: {
|
method: 'POST',
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
headers: {
|
||||||
},
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
body: new URLSearchParams({
|
},
|
||||||
grant_type: 'refresh_token',
|
body: new URLSearchParams({
|
||||||
client_id: 'client-portal',
|
grant_type: 'refresh_token',
|
||||||
client_secret: clientSecret,
|
client_id: 'client-portal',
|
||||||
refresh_token: refreshToken
|
client_secret: clientSecret,
|
||||||
}).toString()
|
refresh_token: refreshToken
|
||||||
}, {
|
}).toString()
|
||||||
timeout: 15000,
|
}, {
|
||||||
retries: 1 // Only 1 retry for refresh operations
|
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() {
|
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