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
NUXT_DOCUMENSO_API_KEY=your_documenso_api_key_here
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
NUXT_DOCUMENSO_TEMPLATE_ID=1
NUXT_DOCUMENSO_CLIENT_RECIPIENT_ID=1
NUXT_DOCUMENSO_DAVID_RECIPIENT_ID=2
NUXT_DOCUMENSO_APPROVAL_RECIPIENT_ID=3
# Webhook Configuration for Embedded Signing
WEBHOOK_SECRET_SIGNING=96BQQRiKkTIN2w0rHbqo7yHggV/sT8702HtHih3uNSY=

View File

@ -198,7 +198,7 @@
{{ getSignatureStatusText('cc') }}
</v-chip>
</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>
<div class="d-flex gap-1">
<v-btn

View File

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

View File

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

View File

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

View File

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

View File

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

75
package-lock.json generated
View File

@ -22,7 +22,6 @@
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"pdfkit": "^0.17.1",
"sharp": "^0.34.2",
"v-phone-input": "^4.4.2",
"vue": "latest",
@ -34,8 +33,7 @@
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17",
"@types/pdfkit": "^0.14.0"
"@types/nodemailer": "^6.4.17"
}
},
"node_modules/@ampproject/remapping": {
@ -4576,16 +4574,6 @@
"integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==",
"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": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@ -6504,12 +6492,6 @@
"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": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -6814,9 +6796,9 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@ -9787,12 +9769,6 @@
"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": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@ -10039,25 +10015,6 @@
"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": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
@ -11389,19 +11346,6 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"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": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
@ -11462,11 +11406,6 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@ -17037,9 +16976,9 @@
"license": "ISC"
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@ -24,7 +24,6 @@
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
"pdfkit": "^0.17.1",
"sharp": "^0.34.2",
"v-phone-input": "^4.4.2",
"vue": "latest",
@ -36,7 +35,6 @@
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17",
"@types/pdfkit": "^0.14.0"
"@types/nodemailer": "^6.4.17"
}
}

View File

@ -291,36 +291,6 @@
v-model="showCreateModal"
@created="handleExpenseCreated"
/>
<!-- PDF Generation Loading Overlay -->
<v-overlay
:model-value="generatingPDF"
persistent
class="align-center justify-center"
>
<v-card
color="surface"
class="pa-8"
width="400"
>
<div class="text-center">
<v-progress-circular
:size="70"
:width="7"
color="primary"
indeterminate
/>
<h3 class="text-h6 mt-4 mb-2">Generating PDF...</h3>
<p class="text-body-2 text-grey-darken-1">
Your expense report is being generated with receipt images
</p>
<p class="text-caption text-grey-darken-1 mt-2">
This may take a moment for large reports
</p>
</div>
</v-card>
</v-overlay>
</div>
</template>
@ -354,7 +324,6 @@ const showDetailsModal = ref(false);
const showCreateModal = ref(false);
const selectedExpense = ref<Expense | null>(null);
const activeTab = ref<string>('');
const generatingPDF = ref(false);
// Filters
const filters = ref({
@ -444,17 +413,7 @@ const fetchExpenses = async () => {
} catch (err: any) {
console.error('[expenses] Error fetching expenses:', err);
// 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.';
}
error.value = err.message || 'Failed to fetch expenses';
} finally {
loading.value = false;
}
@ -525,9 +484,6 @@ const exportCSV = async () => {
};
const generatePDF = async (options: any) => {
generatingPDF.value = true;
showPDFModal.value = false; // Close the modal immediately
try {
console.log('[expenses] Generating PDF with options:', options);
@ -548,33 +504,30 @@ const generatePDF = async (options: any) => {
});
if (response.success && response.data) {
// Decode base64 PDF content
const pdfContent = atob(response.data.content);
// Convert to byte array
const byteNumbers = new Array(pdfContent.length);
for (let i = 0; i < pdfContent.length; i++) {
byteNumbers[i] = pdfContent.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
// Create PDF blob and download
const blob = new Blob([byteArray], { type: 'application/pdf' });
// For now, create HTML file instead of PDF since we're generating HTML content
const htmlContent = atob(response.data.content); // Decode base64
const blob = new Blob([htmlContent], { type: 'text/html' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = response.data.filename;
a.download = `${options.documentName || 'expenses'}.html`;
a.click();
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) {
console.error('[expenses] Error generating PDF:', err);
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 isRefreshing = false
let retryCount = 0
const maxRetries = 3
const scheduleTokenRefresh = (expiresAt: number) => {
// Clear existing timer
@ -14,13 +12,11 @@ export default defineNuxtPlugin(() => {
refreshTimer = null
}
// Calculate time until refresh (refresh 5 minutes before expiry)
const refreshBuffer = 5 * 60 * 1000 // 5 minutes in milliseconds
// Calculate time until refresh (refresh 2 minutes before expiry)
const refreshBuffer = 2 * 60 * 1000 // 2 minutes in milliseconds
const timeUntilRefresh = expiresAt - Date.now() - refreshBuffer
console.log('[AUTH_REFRESH] Scheduling token refresh in:', Math.max(0, timeUntilRefresh), 'ms')
console.log('[AUTH_REFRESH] Token expires at:', new Date(expiresAt))
console.log('[AUTH_REFRESH] Will refresh at:', new Date(expiresAt - refreshBuffer))
// Only schedule if we have time left
if (timeUntilRefresh > 0) {
@ -32,37 +28,20 @@ export default defineNuxtPlugin(() => {
console.log('[AUTH_REFRESH] Attempting automatic token refresh...')
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
method: 'POST',
retry: 2,
retryDelay: 1000
method: 'POST'
})
if (response.success && response.expiresAt) {
console.log('[AUTH_REFRESH] Token refresh successful, scheduling next refresh')
retryCount = 0 // Reset retry count on success
scheduleTokenRefresh(response.expiresAt)
} else {
console.error('[AUTH_REFRESH] Token refresh failed, redirecting to login')
await navigateTo('/login')
}
} catch (error: any) {
} catch (error) {
console.error('[AUTH_REFRESH] Token refresh error:', error)
// 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')
}
// If refresh fails, redirect to login
await navigateTo('/login')
} finally {
isRefreshing = false
}
@ -77,14 +56,11 @@ export default defineNuxtPlugin(() => {
console.log('[AUTH_REFRESH] Token expired, attempting immediate refresh...')
const response = await $fetch<{ success: boolean; expiresAt?: number }>('/api/auth/refresh', {
method: 'POST',
retry: 2,
retryDelay: 1000
method: 'POST'
})
if (response.success && response.expiresAt) {
console.log('[AUTH_REFRESH] Immediate refresh successful')
retryCount = 0 // Reset retry count on success
scheduleTokenRefresh(response.expiresAt)
} else {
console.error('[AUTH_REFRESH] Immediate refresh failed, redirecting to login')
@ -92,19 +68,7 @@ export default defineNuxtPlugin(() => {
}
} catch (error) {
console.error('[AUTH_REFRESH] Immediate refresh error:', error)
// 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')
}
await navigateTo('/login')
} finally {
isRefreshing = false
}
@ -163,20 +127,10 @@ export default defineNuxtPlugin(() => {
// Listen for visibility changes to refresh when tab becomes active
if (typeof document !== 'undefined') {
let lastVisibilityChange = Date.now()
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
const now = Date.now()
const timeSinceLastCheck = now - lastVisibilityChange
// 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
// Tab became visible, check if we need to refresh
checkAndScheduleRefresh()
}
})
}

View File

@ -57,10 +57,7 @@ export default defineEventHandler(async (event) => {
// Documenso API configuration - moved to top for use throughout
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY;
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL;
const templateId = 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');
const templateId = '9';
if (!documensoApiKey || !documensoBaseUrl) {
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`,
subject: "Your LOI is ready to be signed",
redirectUrl: "https://portnimara.com",
distributionMethod: "NONE"
distributionMethod: "SEQUENTIAL"
},
title: `${interest['Full Name']}-EOI-NDA`,
externalId: `loi-${interestId}`,
@ -252,22 +249,22 @@ export default defineEventHandler(async (event) => {
},
recipients: [
{
id: clientRecipientId,
id: 155,
name: interest['Full Name'],
role: "SIGNER",
email: interest['Email Address'],
signingOrder: 1
},
{
id: davidRecipientId,
id: 156,
name: "David Mizrahi",
role: "SIGNER",
email: "dm@portnimara.com",
signingOrder: 3
},
{
id: approvalRecipientId,
name: "Approval",
id: 157,
name: "Oscar Faragher",
role: "APPROVER",
email: "sales@portnimara.com",
signingOrder: 2
@ -340,7 +337,7 @@ export default defineEventHandler(async (event) => {
} else if (recipient.email === 'dm@portnimara.com') {
signingLinks['David Mizrahi'] = recipient.signingUrl;
} else if (recipient.email === 'sales@portnimara.com') {
signingLinks['Approval'] = recipient.signingUrl;
signingLinks['Oscar Faragher'] = recipient.signingUrl;
}
}
});
@ -395,11 +392,11 @@ export default defineEventHandler(async (event) => {
updateData['EmbeddedSignatureLinkDeveloper'] = embeddedDevUrl;
console.log('[EMBEDDED] Developer URL:', signingLinks['David Mizrahi'], '-> Embedded:', embeddedDevUrl);
}
if (signingLinks['Approval']) {
updateData['Signature Link CC'] = signingLinks['Approval'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Approval'], 'cc');
if (signingLinks['Oscar Faragher']) {
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
const embeddedCCUrl = createEmbeddedSigningUrl(signingLinks['Oscar Faragher'], 'cc');
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);

View File

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

View File

@ -78,8 +78,6 @@ export default defineEventHandler(async (event) => {
* Find duplicate expenses based on multiple criteria
*/
function findDuplicateExpenses(expenses: any[]) {
console.log('[EXPENSES] Starting duplicate detection for', expenses.length, 'expenses');
const duplicateGroups: Array<{
id: string;
expenses: any[];
@ -89,7 +87,6 @@ function findDuplicateExpenses(expenses: any[]) {
}> = [];
const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < expenses.length; i++) {
const expense1 = expenses[i];
@ -105,13 +102,8 @@ function findDuplicateExpenses(expenses: any[]) {
if (processedIds.has(expense2.Id)) continue;
const similarity = calculateExpenseSimilarity(expense1, expense2);
comparisons++;
console.log(`[EXPENSES] Comparing ${expense1.Id} vs ${expense2.Id}: score=${similarity.score.toFixed(3)}, threshold=0.7`);
if (similarity.score >= 0.7) { // Lower threshold for expenses
console.log(`[EXPENSES] MATCH FOUND! ${expense1.Id} vs ${expense2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[EXPENSES] Match reasons:', similarity.reasons);
if (similarity.score >= 0.8) {
matches.push(expense2);
processedIds.add(expense2.Id);
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));
try {
// Set proper headers
setHeader(event, 'Cache-Control', 'no-cache');
setHeader(event, 'Content-Type', 'application/json');
// Check authentication first
// Check authentication
try {
await requireSalesOrAdmin(event);
console.log('[get-expenses] Authentication successful');
} catch (authError: any) {
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.'
});
}
} catch (error: any) {
console.error('[get-expenses] Top-level error:', error);
// If it's already a proper H3 error, re-throw it
if (error.statusCode) {
throw error;
}
// Handle authentication errors specifically
if (error.message?.includes('authentication') || error.message?.includes('auth')) {
} catch (authError: any) {
if (authError.statusCode === 403) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required. Please log in again.'
statusCode: 403,
statusMessage: 'Access denied. This feature requires sales team or administrator privileges.'
});
}
// 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.'
});
throw authError;
}
});

View File

@ -86,9 +86,6 @@ export default defineEventHandler(async (event) => {
* Find duplicate interests based on multiple criteria
*/
function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
console.log('[INTERESTS] Starting duplicate detection with threshold:', threshold);
console.log('[INTERESTS] Total interests to analyze:', interests.length);
const duplicateGroups: Array<{
id: string;
interests: any[];
@ -98,7 +95,6 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
}> = [];
const processedIds = new Set<number>();
let comparisons = 0;
for (let i = 0; i < interests.length; i++) {
const interest1 = interests[i];
@ -113,21 +109,14 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
if (processedIds.has(interest2.Id)) continue;
const similarity = calculateSimilarity(interest1, interest2);
comparisons++;
console.log(`[INTERESTS] Comparing ${interest1.Id} vs ${interest2.Id}: score=${similarity.score.toFixed(3)}, threshold=${threshold}`);
if (similarity.score >= threshold) {
console.log(`[INTERESTS] MATCH FOUND! ${interest1.Id} vs ${interest2.Id} (score: ${similarity.score.toFixed(3)})`);
console.log('[INTERESTS] Match details:', similarity.details);
matches.push(interest2);
processedIds.add(interest2.Id);
}
}
if (matches.length > 1) {
console.log(`[INTERESTS] Creating duplicate group with ${matches.length} matches`);
// Mark all as processed
matches.forEach(match => processedIds.add(match.Id));
@ -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;
}
@ -159,67 +147,36 @@ function findDuplicateInterests(interests: any[], threshold: number = 0.8) {
function calculateSimilarity(interest1: any, interest2: any) {
const scores: Array<{ type: string; score: number; weight: number }> = [];
console.log(`[INTERESTS] Calculating similarity between:`, {
id1: interest1.Id,
name1: interest1['Full Name'],
email1: interest1['Email Address'],
phone1: interest1['Phone Number'],
id2: interest2.Id,
name2: interest2['Full Name'],
email2: interest2['Email Address'],
phone2: interest2['Phone Number']
});
// Email similarity (highest weight) - exact match required
// Email similarity (highest weight)
if (interest1['Email Address'] && interest2['Email Address']) {
const email1 = normalizeEmail(interest1['Email Address']);
const email2 = normalizeEmail(interest2['Email Address']);
const emailScore = email1 === email2 ? 1.0 : 0.0;
scores.push({ type: 'email', score: emailScore, weight: 0.5 });
console.log(`[INTERESTS] Email comparison: "${email1}" vs "${email2}" = ${emailScore}`);
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 - exact match on normalized numbers
// 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 && phone1.length >= 8 ? 1.0 : 0.0; // Require at least 8 digits
scores.push({ type: 'phone', score: phoneScore, weight: 0.4 });
console.log(`[INTERESTS] Phone comparison: "${phone1}" vs "${phone2}" = ${phoneScore}`);
const phoneScore = phone1 === phone2 ? 1.0 : 0.0;
scores.push({ type: 'phone', score: phoneScore, weight: 0.3 });
}
// Name similarity - fuzzy matching
// 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.3 });
console.log(`[INTERESTS] Name comparison: "${interest1['Full Name']}" vs "${interest2['Full Name']}" = ${nameScore.toFixed(3)}`);
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.2 });
console.log(`[INTERESTS] Address comparison: ${addressScore.toFixed(3)}`);
scores.push({ type: 'address', score: addressScore, weight: 0.1 });
}
// Special case: if we have exact email OR phone match, give high score regardless of other fields
const hasExactEmailMatch = scores.find(s => s.type === 'email' && s.score === 1.0);
const hasExactPhoneMatch = scores.find(s => s.type === 'phone' && s.score === 1.0);
if (hasExactEmailMatch || hasExactPhoneMatch) {
console.log('[INTERESTS] Exact email or phone match found - high confidence');
return {
score: 0.95, // High confidence for exact email/phone match
details: scores
};
}
// Calculate weighted average for other cases
// 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);
console.log(`[INTERESTS] Weighted score: ${weightedScore.toFixed(3)} (weights: ${totalWeight})`);
return {
score: weightedScore,
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
*/
@ -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
*/
export const processExpenseWithCurrency = async (expense: any, targetCurrency: string = 'EUR'): Promise<any> => {
export const processExpenseWithCurrency = async (expense: any): Promise<any> => {
const processedExpense = { ...expense };
// Parse price number
const priceNumber = parseFloat(expense.Price?.toString().replace(/[^\d.-]/g, '')) || 0;
processedExpense.PriceNumber = priceNumber;
// Get currency code and symbol
// Get currency symbol
const currencyCode = expense.currency || 'USD';
processedExpense.Currency = currencyCode;
processedExpense.CurrencySymbol = getCurrencySymbol(currencyCode);
// Convert to target currency if not already in target
const targetCurrencyUpper = targetCurrency.toUpperCase();
const targetField = `Price${targetCurrencyUpper}`;
if (currencyCode.toUpperCase() !== targetCurrencyUpper) {
const conversion = await convertToTargetCurrency(priceNumber, currencyCode, targetCurrency);
// Convert to USD if not already USD
if (currencyCode.toUpperCase() !== 'USD') {
const conversion = await convertToUSD(priceNumber, currencyCode);
if (conversion) {
processedExpense[targetField] = conversion.targetAmount;
processedExpense.PriceUSD = conversion.usdAmount;
processedExpense.ConversionRate = conversion.rate;
processedExpense.ConversionDate = conversion.conversionDate;
processedExpense.TargetCurrency = targetCurrencyUpper;
}
} else {
// If already in target currency, set target amount to original amount
processedExpense[targetField] = priceNumber;
// If already USD, set USD amount to original amount
processedExpense.PriceUSD = priceNumber;
processedExpense.ConversionRate = 1.0;
processedExpense.ConversionDate = new Date().toISOString();
processedExpense.TargetCurrency = targetCurrencyUpper;
}
// Also convert to USD and EUR for compatibility
if (currencyCode.toUpperCase() !== 'USD') {
const usdConversion = await convertToUSD(priceNumber, currencyCode);
if (usdConversion) {
processedExpense.PriceUSD = usdConversion.usdAmount;
}
} else {
processedExpense.PriceUSD = priceNumber;
}
if (currencyCode.toUpperCase() !== 'EUR') {
const eurConversion = await convertToEUR(priceNumber, currencyCode);
if (eurConversion) {
processedExpense.PriceEUR = eurConversion.eurAmount;
}
} else {
processedExpense.PriceEUR = priceNumber;
}
// Create display prices
processedExpense.DisplayPrice = formatPriceWithCurrency(priceNumber, currencyCode);
processedExpense.DisplayPrice = createDisplayPrice(
priceNumber,
currencyCode,
processedExpense.PriceUSD
);
// Create display price with target currency conversion
const targetAmount = processedExpense[targetField];
if (currencyCode.toUpperCase() !== targetCurrencyUpper && targetAmount) {
const targetSymbol = getCurrencySymbol(targetCurrency);
processedExpense.DisplayPriceWithTarget = `${formatPriceWithCurrency(priceNumber, currencyCode)} (${targetSymbol}${targetAmount.toFixed(2)})`;
} else {
processedExpense.DisplayPriceWithTarget = formatPriceWithCurrency(priceNumber, currencyCode);
}
processedExpense.DisplayPriceTarget = formatPriceWithCurrency(
targetAmount || priceNumber,
targetCurrency
processedExpense.DisplayPriceUSD = formatPriceWithCurrency(
processedExpense.PriceUSD || priceNumber,
'USD'
);
return processedExpense;