Compare commits

..

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

18 changed files with 412 additions and 1619 deletions

View File

@ -17,10 +17,6 @@ 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=

View File

@ -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' : ''">Approval</v-list-item-subtitle> <v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</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

View File

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

View File

@ -50,16 +50,15 @@ const checkForDuplicates = async () => {
try { try {
loading.value = true; loading.value = true;
// Check roles with better error handling - use hasAnyRole for multiple roles // Check roles with better error handling
const { hasAnyRole, isAdmin, isSalesOrAdmin } = useAuthorization();
let canViewDuplicates = false; let canViewDuplicates = false;
try { try {
canViewDuplicates = isSalesOrAdmin(); // Use the convenience method canViewDuplicates = await hasRole(['sales', 'admin']);
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);
} }

View File

@ -103,19 +103,6 @@
</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"
@ -132,21 +119,8 @@
</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" md="6"> <v-col cols="12">
<v-select <v-select
v-model="options.pageFormat" v-model="options.pageFormat"
:items="pageFormatOptions" :items="pageFormatOptions"
@ -230,12 +204,10 @@ 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
@ -253,12 +225,10 @@ 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
@ -275,11 +245,6 @@ 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'

View File

@ -20,7 +20,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
// Use a cached auth state to avoid excessive API calls // Use a cached auth state to avoid excessive API calls
const nuxtApp = useNuxtApp(); const nuxtApp = useNuxtApp();
const cacheKey = 'auth:session:cache'; const cacheKey = 'auth:session:cache';
const cacheExpiry = 5 * 60 * 1000; // 5 minutes cache (increased from 30 seconds) const cacheExpiry = 30000; // 30 seconds cache
// Check if we have a cached session // Check if we have a cached session
const cachedSession = nuxtApp.payload.data?.[cacheKey]; const cachedSession = nuxtApp.payload.data?.[cacheKey];
@ -44,17 +44,14 @@ export default defineNuxtRouteMiddleware(async (to) => {
} }
try { try {
// Check Keycloak authentication via session API with timeout and retries // Check Keycloak authentication via session API with timeout
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000); // 10 second timeout (increased from 5) const timeout = setTimeout(() => controller.abort(), 5000); // 5 second timeout
const sessionData = await $fetch('/api/auth/session', { const sessionData = await $fetch('/api/auth/session', {
signal: controller.signal, signal: controller.signal,
retry: 2, // Increased retry count retry: 1,
retryDelay: 1000, // Increased retry delay retryDelay: 500
onRetry: ({ retries }: { retries: number }) => {
console.log(`[MIDDLEWARE] Retrying auth check (attempt ${retries + 1})`)
}
}) as any; }) as any;
clearTimeout(timeout); clearTimeout(timeout);
@ -103,11 +100,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
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 // If it's a network error or timeout, check if we have a recent cached session
if (error.name === 'AbortError' || error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') { if (error.name === 'AbortError' || error.code === 'ECONNREFUSED') {
console.log('[MIDDLEWARE] Network error, checking for recent cache'); console.log('[MIDDLEWARE] Network error, checking for recent cache');
const recentCache = nuxtApp.payload.data?.[cacheKey]; const recentCache = nuxtApp.payload.data?.[cacheKey];
if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 30 * 60 * 1000) { // 30 minutes grace period if (recentCache && recentCache.timestamp && (now - recentCache.timestamp) < 300000) { // 5 minutes
console.log('[MIDDLEWARE] Using recent cache despite network error (age:', Math.round((now - recentCache.timestamp) / 1000), 'seconds)'); console.log('[MIDDLEWARE] Using recent cache despite network error');
if (recentCache.authenticated && recentCache.user) { if (recentCache.authenticated && recentCache.user) {
// Store auth state for components // Store auth state for components
if (!nuxtApp.payload.data) { if (!nuxtApp.payload.data) {
@ -118,13 +115,6 @@ export default defineNuxtRouteMiddleware(async (to) => {
authenticated: recentCache.authenticated, authenticated: recentCache.authenticated,
groups: recentCache.groups || [] groups: recentCache.groups || []
}; };
// Show a warning toast if cache is older than 10 minutes
if ((now - recentCache.timestamp) > 10 * 60 * 1000) {
const toast = useToast();
toast.warning('Network connectivity issue - using cached authentication');
}
return; return;
} }
} }

View File

@ -79,7 +79,6 @@ 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,
@ -95,9 +94,7 @@ export default defineNuxtConfig({
} }
} }
} }
], ]
skipWaiting: true,
clientsClaim: true
}, },
client: { client: {
installPrompt: true, installPrompt: true,

75
package-lock.json generated
View File

@ -22,7 +22,6 @@
"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",
@ -34,8 +33,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"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@ -4576,16 +4574,6 @@
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==", "integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pdfkit": {
"version": "0.14.0",
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.14.0.tgz",
"integrity": "sha512-X94hoZVr9dNfV23roeXRm57AWS+AOMak3gq2wZvn4TXiLvXE8+TrYaM5IkMyZbGRw49jEqI49rP/UVL3+C3Svg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -6504,12 +6492,6 @@
"uncrypto": "^0.1.3" "uncrypto": "^0.1.3"
} }
}, },
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/crypto-random-string": { "node_modules/crypto-random-string": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -6814,9 +6796,9 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.1", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "ms": "^2.1.3"
@ -9787,12 +9769,6 @@
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
}, },
"node_modules/jpeg-exif": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
"license": "MIT"
},
"node_modules/js-cookie": { "node_modules/js-cookie": {
"version": "3.0.5", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@ -10039,25 +10015,6 @@
"url": "https://github.com/sponsors/antonk52" "url": "https://github.com/sponsors/antonk52"
} }
}, },
"node_modules/linebreak": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
"license": "MIT",
"dependencies": {
"base64-js": "0.0.8",
"unicode-trie": "^2.0.0"
}
},
"node_modules/linebreak/node_modules/base64-js": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/linkify-it": { "node_modules/linkify-it": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -11389,19 +11346,6 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfkit": {
"version": "0.17.1",
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.1.tgz",
"integrity": "sha512-Kkf1I9no14O/uo593DYph5u3QwiMfby7JsBSErN1WqeyTgCBNJE3K4pXBn3TgkdKUIVu+buSl4bYUNC+8Up4xg==",
"license": "MIT",
"dependencies": {
"crypto-js": "^4.2.0",
"fontkit": "^2.0.4",
"jpeg-exif": "^1.1.4",
"linebreak": "^1.1.0",
"png-js": "^1.0.0"
}
},
"node_modules/peberminta": { "node_modules/peberminta": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
@ -11462,11 +11406,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/png-js": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -17037,9 +16976,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": { "node_modules/ws": {
"version": "8.18.3", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"

View File

@ -24,7 +24,6 @@
"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",
@ -36,7 +35,6 @@
"@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"
} }
} }

View File

@ -291,36 +291,6 @@
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>
@ -354,7 +324,6 @@ 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({
@ -444,17 +413,7 @@ 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;
} }
@ -525,9 +484,6 @@ 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);
@ -548,33 +504,30 @@ const generatePDF = async (options: any) => {
}); });
if (response.success && response.data) { if (response.success && response.data) {
// Decode base64 PDF content // For now, create HTML file instead of PDF since we're generating HTML content
const pdfContent = atob(response.data.content); const htmlContent = atob(response.data.content); // Decode base64
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 = response.data.filename; a.download = `${options.documentName || 'expenses'}.html`;
a.click(); a.click();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
console.log('[expenses] PDF downloaded successfully:', response.data.filename); // Also open in new tab for immediate viewing
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;
} }
}; };

View File

@ -4,8 +4,6 @@ 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
@ -14,13 +12,11 @@ export default defineNuxtPlugin(() => {
refreshTimer = null refreshTimer = null
} }
// Calculate time until refresh (refresh 5 minutes before expiry) // Calculate time until refresh (refresh 2 minutes before expiry)
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds const refreshBuffer = 2 * 60 * 1000 // 2 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) {
@ -32,37 +28,20 @@ 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: any) { } catch (error) {
console.error('[AUTH_REFRESH] Token refresh error:', error) console.error('[AUTH_REFRESH] Token refresh error:', error)
// If refresh fails, redirect to login
// Implement exponential backoff retry await navigateTo('/login')
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
} }
@ -77,14 +56,11 @@ 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')
@ -92,19 +68,7 @@ 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
} }
@ -163,20 +127,10 @@ 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') {
let lastVisibilityChange = Date.now()
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (!document.hidden) { if (!document.hidden) {
const now = Date.now() // Tab became visible, check if we need to refresh
const timeSinceLastCheck = now - lastVisibilityChange checkAndScheduleRefresh()
// If tab was hidden for more than 1 minute, check auth status
if (timeSinceLastCheck > 60000) {
console.log('[AUTH_REFRESH] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds, checking auth status')
checkAndScheduleRefresh()
}
lastVisibilityChange = now
} }
}) })
} }

View File

@ -57,10 +57,7 @@ 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 = process.env.NUXT_DOCUMENSO_TEMPLATE_ID || '1'; const templateId = '9';
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({
@ -234,7 +231,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: "NONE" distributionMethod: "SEQUENTIAL"
}, },
title: `${interest['Full Name']}-EOI-NDA`, title: `${interest['Full Name']}-EOI-NDA`,
externalId: `loi-${interestId}`, externalId: `loi-${interestId}`,
@ -252,22 +249,22 @@ export default defineEventHandler(async (event) => {
}, },
recipients: [ recipients: [
{ {
id: clientRecipientId, id: 155,
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: davidRecipientId, id: 156,
name: "David Mizrahi", name: "David Mizrahi",
role: "SIGNER", role: "SIGNER",
email: "dm@portnimara.com", email: "dm@portnimara.com",
signingOrder: 3 signingOrder: 3
}, },
{ {
id: approvalRecipientId, id: 157,
name: "Approval", name: "Oscar Faragher",
role: "APPROVER", role: "APPROVER",
email: "sales@portnimara.com", email: "sales@portnimara.com",
signingOrder: 2 signingOrder: 2
@ -340,7 +337,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['Approval'] = recipient.signingUrl; signingLinks['Oscar Faragher'] = recipient.signingUrl;
} }
} }
}); });
@ -395,11 +392,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['Approval']) { if (signingLinks['Oscar Faragher']) {
updateData['Signature Link CC'] = signingLinks['Approval']; updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc'); const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl; updateData['EmbeddedSignatureLinkCC'] = embeddedCCUrl;
console.log('[EMBEDDED] CC URL:', signingLinks['Approval'], '-> Embedded:', embeddedCCUrl); console.log('[EMBEDDED] CC URL:', signingLinks['Oscar Faragher'], '-> Embedded:', embeddedCCUrl);
} }
console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData); console.log('[EMBEDDED] Final updateData being sent to NocoDB:', updateData);

View File

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

View File

@ -78,8 +78,6 @@ export default defineEventHandler(async (event) => {
* Find duplicate expenses based on multiple criteria * Find duplicate expenses based on multiple criteria
*/ */
function findDuplicateExpenses(expenses: any[]) { function findDuplicateExpenses(expenses: any[]) {
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
const duplicateGroups: Array<{ const duplicateGroups: Array<{
id: string; id: string;
expenses: any[]; expenses: any[];
@ -89,7 +87,6 @@ function findDuplicateExpenses(expenses: any[]) {
}> = []; }> = [];
const processedIds = new Set<number>(); const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < expenses.length; i++) { for (let i = 0; i < expenses.length; i++) {
const expense1 = expenses[i]; const expense1 = expenses[i];
@ -105,13 +102,8 @@ function findDuplicateExpenses(expenses: any[]) {
if (processedIds.has(expense2.Id)) continue; if (processedIds.has(expense2.Id)) continue;
const similarity = calculateExpenseSimilarity(expense1, expense2); const similarity = calculateExpenseSimilarity(expense1, expense2);
comparisons++;
console.log(`[EXPENSES] Comparing ${expense1.Id} vs ${expense2.Id}: score=${similarity.score.toFixed(3)}, threshold=0.7`); if (similarity.score >= 0.8) {
if (similarity.score >= 0.7) { // Lower threshold for expenses
console.log(`[EXPENSES] MATCH FOUND! ${expense1.Id} vs ${expense2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[EXPENSES] Match reasons:', similarity.reasons);
matches.push(expense2); matches.push(expense2);
processedIds.add(expense2.Id); processedIds.add(expense2.Id);
similarity.reasons.forEach(r => matchReasons.add(r)); similarity.reasons.forEach(r => matchReasons.add(r));

File diff suppressed because it is too large Load Diff

View File

@ -7,13 +7,9 @@ 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 {
// Set proper headers // Check authentication
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);
@ -131,34 +127,14 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Unable to fetch expense data. Please try again later.' statusMessage: 'Unable to fetch expense data. Please try again later.'
}); });
} }
} catch (error: any) { } catch (authError: any) {
console.error('[get-expenses] Top-level error:', error); if (authError.statusCode === 403) {
// 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: 401, statusCode: 403,
statusMessage: 'Authentication required. Please log in again.' statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
}); });
} }
// Handle database connection errors throw authError;
if (error.message?.includes('database') || error.message?.includes('connection')) {
throw createError({
statusCode: 503,
statusMessage: 'Database temporarily unavailable. Please try again later.'
});
}
// Generic server error for anything else
throw createError({
statusCode: 500,
statusMessage: 'An unexpected error occurred. Please try again later.'
});
} }
}); });

View File

@ -86,9 +86,6 @@ export default defineEventHandler(async (event) => {
* Find duplicate interests based on multiple criteria * Find duplicate interests based on multiple criteria
*/ */
function findDuplicateInterests(interests: any[], threshold: number = 0.8) { function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
console.log('[INTERESTS] Starting duplicate detection with threshold:', threshold);
console.log('[INTERESTS] Total interests to analyze:', interests.length);
const duplicateGroups: Array<{ const duplicateGroups: Array<{
id: string; id: string;
interests: any[]; interests: any[];
@ -98,7 +95,6 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
}> = []; }> = [];
const processedIds = new Set<number>(); const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < interests.length; i++) { for (let i = 0; i < interests.length; i++) {
const interest1 = interests[i]; const interest1 = interests[i];
@ -113,21 +109,14 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
if (processedIds.has(interest2.Id)) continue; if (processedIds.has(interest2.Id)) continue;
const similarity = calculateSimilarity(interest1, interest2); const similarity = calculateSimilarity(interest1, interest2);
comparisons++;
console.log(`[INTERESTS] Comparing ${interest1.Id} vs ${interest2.Id}: score=${similarity.score.toFixed(3)}, threshold=${threshold}`);
if (similarity.score >= threshold) { if (similarity.score >= threshold) {
console.log(`[INTERESTS] MATCH FOUND! ${interest1.Id} vs ${interest2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[INTERESTS] Match details:', similarity.details);
matches.push(interest2); matches.push(interest2);
processedIds.add(interest2.Id); processedIds.add(interest2.Id);
} }
} }
if (matches.length > 1) { if (matches.length > 1) {
console.log(`[INTERESTS] Creating duplicate group with ${matches.length} matches`);
// Mark all as processed // Mark all as processed
matches.forEach(match => processedIds.add(match.Id)); matches.forEach(match => processedIds.add(match.Id));
@ -149,7 +138,6 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
} }
} }
console.log(`[INTERESTS] Completed ${comparisons} comparisons, found ${duplicateGroups.length} duplicate groups`);
return duplicateGroups; return duplicateGroups;
} }
@ -158,68 +146,37 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
*/ */
function calculateSimilarity(interest1: any, interest2: any) { function calculateSimilarity(interest1: any, interest2: any) {
const scores: Array<{ type: string; score: number; weight: number }> = []; const scores: Array<{ type: string; score: number; weight: number }> = [];
console.log(`[INTERESTS] Calculating similarity between:`, {
id1: interest1.Id,
name1: interest1['Full Name'],
email1: interest1['Email Address'],
phone1: interest1['Phone Number'],
id2: interest2.Id,
name2: interest2['Full Name'],
email2: interest2['Email Address'],
phone2: interest2['Phone Number']
});
// Email similarity (highest weight) - exact match required // Email similarity (highest weight)
if (interest1['Email Address'] && interest2['Email Address']) { if (interest1['Email Address'] && interest2['Email Address']) {
const email1 = normalizeEmail(interest1['Email Address']); const emailScore = normalizeEmail(interest1['Email Address']) === normalizeEmail(interest2['Email Address']) ? 1.0 : 0.0;
const email2 = normalizeEmail(interest2['Email Address']); scores.push({ type: 'email', score: emailScore, weight: 0.4 });
const emailScore = email1 === email2 ? 1.0 : 0.0;
scores.push({ type: 'email', score: emailScore, weight: 0.5 });
console.log(`[INTERESTS] Email comparison: "${email1}" vs "${email2}" = ${emailScore}`);
} }
// Phone similarity - exact match on normalized numbers // Phone similarity
if (interest1['Phone Number'] && interest2['Phone Number']) { if (interest1['Phone Number'] && interest2['Phone Number']) {
const phone1 = normalizePhone(interest1['Phone Number']); const phone1 = normalizePhone(interest1['Phone Number']);
const phone2 = normalizePhone(interest2['Phone Number']); const phone2 = normalizePhone(interest2['Phone Number']);
const phoneScore = phone1 === phone2 && phone1.length >= 8 ? 1.0 : 0.0; // Require at least 8 digits const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
scores.push({ type: 'phone', score: phoneScore, weight: 0.4 }); scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
console.log(`[INTERESTS] Phone comparison: "${phone1}" vs "${phone2}" = ${phoneScore}`);
} }
// Name similarity - fuzzy matching // Name similarity
if (interest1['Full Name'] && interest2['Full Name']) { if (interest1['Full Name'] && interest2['Full Name']) {
const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']); const nameScore = calculateNameSimilarity(interest1['Full Name'], interest2['Full Name']);
scores.push({ type: 'name', score: nameScore, weight: 0.3 }); scores.push({ type: 'name', score: nameScore, weight: 0.2 });
console.log(`[INTERESTS] Name comparison: "${interest1['Full Name']}" vs "${interest2['Full Name']}" = ${nameScore.toFixed(3)}`);
} }
// Address similarity // Address similarity
if (interest1.Address && interest2.Address) { if (interest1.Address && interest2.Address) {
const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address); const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address);
scores.push({ type: 'address', score: addressScore, weight: 0.2 }); scores.push({ type: 'address', score: addressScore, weight: 0.1 });
console.log(`[INTERESTS] Address comparison: ${addressScore.toFixed(3)}`);
} }
// Special case: if we have exact email OR phone match, give high score regardless of other fields // Calculate weighted average
const hasExactEmailMatch = scores.find(s => s.type === 'email' && s.score === 1.0);
const hasExactPhoneMatch = scores.find(s => s.type === 'phone' && s.score === 1.0);
if (hasExactEmailMatch || hasExactPhoneMatch) {
console.log('[INTERESTS] Exact email or phone match found - high confidence');
return {
score: 0.95, // High confidence for exact email/phone match
details: scores
};
}
// Calculate weighted average for other cases
const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0); const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0);
const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1); const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1);
console.log(`[INTERESTS] Weighted score: ${weightedScore.toFixed(3)} (weights: ${totalWeight})`);
return { return {
score: weightedScore, score: weightedScore,
details: scores details: scores

View File

@ -303,80 +303,6 @@ 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
*/ */
@ -477,160 +403,46 @@ 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, targetCurrency: string = 'EUR'): Promise<any> => { export const processExpenseWithCurrency = async (expense: any): 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 code and symbol // Get currency symbol
const currencyCode = expense.currency || 'USD'; const currencyCode = expense.currency || 'USD';
processedExpense.Currency = currencyCode;
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode); processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
// Convert to target currency if not already in target // Convert to USD if not already USD
const targetCurrencyUpper = targetCurrency.toUpperCase(); if (currencyCode.toUpperCase() !== 'USD') {
const targetField = `Price${targetCurrencyUpper}`; const conversion = await convertToUSD(priceNumber, currencyCode);
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
if (conversion) { if (conversion) {
processedExpense[targetField] = conversion.targetAmount; processedExpense.PriceUSD = conversion.usdAmount;
processedExpense.ConversionRate = conversion.rate; processedExpense.ConversionRate = conversion.rate;
processedExpense.ConversionDate = conversion.conversionDate; processedExpense.ConversionDate = conversion.conversionDate;
processedExpense.TargetCurrency = targetCurrencyUpper;
} }
} else { } else {
// If already in target currency, set target amount to original amount // If already USD, set USD amount to original amount
processedExpense[targetField] = priceNumber; processedExpense.PriceUSD = 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 = formatPriceWithCurrency(priceNumber, currencyCode); processedExpense.DisplayPrice = createDisplayPrice(
priceNumber,
currencyCode,
processedExpense.PriceUSD
);
// Create display price with target currency conversion processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
const targetAmount = processedExpense[targetField]; processedExpense.PriceUSD || priceNumber,
if (currencyCode.toUpperCase() !== targetCurrencyUpper && targetAmount) { 'USD'
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;