Improve email session management and add IMAP connection pooling
- Switch from localStorage to sessionStorage for email sessions - Add session validation on component mount - Implement IMAP connection pool with folder search capabilities - Add operation locking utility for concurrent request handling - Refactor EOI section component structure - Update API endpoints for better email thread management
This commit is contained in:
parent
c8d8042797
commit
64c35b70f8
|
|
@ -32,8 +32,9 @@
|
||||||
:loading="isRefreshing"
|
:loading="isRefreshing"
|
||||||
size="small"
|
size="small"
|
||||||
color="grey-darken-4"
|
color="grey-darken-4"
|
||||||
|
icon="mdi-refresh"
|
||||||
|
class="ml-4"
|
||||||
>
|
>
|
||||||
<v-icon>mdi-refresh</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="bottom">Refresh Emails</v-tooltip>
|
<v-tooltip activator="parent" location="bottom">Refresh Emails</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
@ -625,21 +626,47 @@ const allAttachments = computed(() => {
|
||||||
return [...uploaded, ...browser];
|
return [...uploaded, ...browser];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for stored session on mount
|
// Check for stored session on mount and validate it
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const storedSession = localStorage.getItem('emailSessionId');
|
// Use sessionStorage instead of localStorage for email sessions to require re-auth on page reload
|
||||||
|
const storedSession = sessionStorage.getItem('emailSessionId');
|
||||||
|
|
||||||
if (storedSession) {
|
if (storedSession) {
|
||||||
|
// Validate the session is still active
|
||||||
|
try {
|
||||||
|
const testResponse = await $fetch('/api/email/test-connection', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-tag': '094ut234'
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
sessionId: storedSession
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (testResponse.success) {
|
||||||
sessionId.value = storedSession;
|
sessionId.value = storedSession;
|
||||||
hasEmailCredentials.value = true;
|
hasEmailCredentials.value = true;
|
||||||
|
} else {
|
||||||
|
// Session invalid, clear it
|
||||||
|
sessionStorage.removeItem('emailSessionId');
|
||||||
|
hasEmailCredentials.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Connection test failed, clear session
|
||||||
|
console.log('[ClientEmailSection] Email session validation failed, clearing session');
|
||||||
|
sessionStorage.removeItem('emailSessionId');
|
||||||
|
hasEmailCredentials.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load signature config
|
// Load signature config (this can persist in localStorage)
|
||||||
const storedSignature = localStorage.getItem('emailSignature');
|
const storedSignature = localStorage.getItem('emailSignature');
|
||||||
if (storedSignature) {
|
if (storedSignature) {
|
||||||
signatureConfig.value = JSON.parse(storedSignature);
|
signatureConfig.value = JSON.parse(storedSignature);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load email thread immediately if credentials are available
|
// Load email thread immediately if credentials are available and valid
|
||||||
if (hasEmailCredentials.value) {
|
if (hasEmailCredentials.value) {
|
||||||
loadEmailThread();
|
loadEmailThread();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,784 +1,10 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<v-card-title class="text-h6 d-flex align-center pb-4">
|
|
||||||
<v-icon class="mr-2" color="primary">mdi-file-document-edit</v-icon>
|
|
||||||
EOI Management
|
|
||||||
</v-card-title>
|
|
||||||
<v-card-text class="pt-0">
|
|
||||||
|
|
||||||
<!-- EOI Documents Section -->
|
|
||||||
<div v-if="hasEOIDocuments" class="mb-4">
|
|
||||||
<div class="text-subtitle-1 mb-2">EOI Documents</div>
|
|
||||||
<div class="d-flex flex-wrap ga-2">
|
|
||||||
<v-chip
|
|
||||||
v-for="(doc, index) in eoiDocuments"
|
|
||||||
:key="index"
|
|
||||||
color="primary"
|
|
||||||
variant="tonal"
|
|
||||||
prepend-icon="mdi-file-pdf-box"
|
|
||||||
:href="doc.url"
|
|
||||||
target="_blank"
|
|
||||||
component="a"
|
|
||||||
clickable
|
|
||||||
closable
|
|
||||||
@click:close="removeDocument(index)"
|
|
||||||
>
|
|
||||||
{{ doc.title || `EOI Document ${index + 1}` }}
|
|
||||||
</v-chip>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Generate EOI Button - Only show if no documents uploaded and no generated EOI -->
|
|
||||||
<div v-if="!hasGeneratedEOI && !hasEOIDocuments" class="d-flex flex-wrap gap-2">
|
|
||||||
<v-btn
|
|
||||||
@click="generateEOI"
|
|
||||||
:loading="isGenerating"
|
|
||||||
color="success"
|
|
||||||
variant="flat"
|
|
||||||
prepend-icon="mdi-file-document-plus"
|
|
||||||
:size="mobile ? 'default' : 'large'"
|
|
||||||
:class="mobile ? 'flex-grow-1' : ''"
|
|
||||||
>
|
|
||||||
Generate EOI
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
@click="showUploadDialog = true"
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-upload"
|
|
||||||
:disabled="isUploading"
|
|
||||||
:size="mobile ? 'default' : 'large'"
|
|
||||||
:class="mobile ? 'flex-grow-1' : ''"
|
|
||||||
>
|
|
||||||
Upload EOI Document
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Message when uploaded documents exist -->
|
|
||||||
<div v-else-if="hasEOIDocuments && !hasGeneratedEOI" class="mb-4">
|
|
||||||
<v-alert
|
|
||||||
type="info"
|
|
||||||
variant="tonal"
|
|
||||||
class="mb-3"
|
|
||||||
>
|
|
||||||
Uploaded EOI documents found. Remove uploaded documents to generate a new EOI.
|
|
||||||
</v-alert>
|
|
||||||
<v-btn
|
|
||||||
@click="showUploadDialog = true"
|
|
||||||
variant="tonal"
|
|
||||||
color="success"
|
|
||||||
prepend-icon="mdi-upload"
|
|
||||||
:disabled="isUploading"
|
|
||||||
:size="mobile ? 'default' : 'large'"
|
|
||||||
>
|
|
||||||
Upload Additional EOI
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Upload EOI Button - Show if generated EOI exists -->
|
|
||||||
<div v-else-if="hasGeneratedEOI" class="mb-4">
|
|
||||||
<v-btn
|
|
||||||
@click="showUploadDialog = true"
|
|
||||||
variant="tonal"
|
|
||||||
color="success"
|
|
||||||
prepend-icon="mdi-upload"
|
|
||||||
:disabled="isUploading"
|
|
||||||
:size="mobile ? 'default' : 'large'"
|
|
||||||
>
|
|
||||||
Upload Signed EOI
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- EOI Status Badge -->
|
|
||||||
<div v-if="hasGeneratedEOI || hasEOIDocuments" class="mb-4 d-flex align-center flex-wrap">
|
|
||||||
<v-chip
|
|
||||||
:color="getStatusColor(interest['EOI Status'])"
|
|
||||||
variant="tonal"
|
|
||||||
:prepend-icon="!mobile ? 'mdi-file-document-check' : undefined"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
class="mb-1"
|
|
||||||
>
|
|
||||||
{{ interest['EOI Status'] }}
|
|
||||||
</v-chip>
|
|
||||||
<span v-if="interest['EOI Time Sent']" class="text-caption text-grey-darken-1 ml-3">
|
|
||||||
{{ mobile ? '' : 'Sent: ' }}{{ formatDate(interest['EOI Time Sent']) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Signature Status Section - Only show if EOI is generated but not signed -->
|
|
||||||
<div v-if="hasGeneratedEOI && !isEOISigned" class="mb-4">
|
|
||||||
<div class="text-subtitle-2 mb-2 d-flex align-center">
|
|
||||||
Signature Status
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-refresh"
|
|
||||||
variant="tonal"
|
|
||||||
size="x-small"
|
|
||||||
class="ml-2"
|
|
||||||
color="primary"
|
|
||||||
@click="checkSignatureStatus"
|
|
||||||
:loading="isCheckingSignatures"
|
|
||||||
>
|
|
||||||
<v-icon size="x-small">mdi-refresh</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Refresh signature status
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-list :density="mobile ? 'compact' : 'comfortable'">
|
|
||||||
<v-list-item class="mb-2">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-avatar :color="getSignatureStatusColor('client')" :size="mobile ? 32 : 40">
|
|
||||||
<v-icon :size="mobile ? 'small' : 'default'">
|
|
||||||
{{ getSignatureStatusIcon('client') }}
|
|
||||||
</v-icon>
|
|
||||||
</v-avatar>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title :class="mobile ? 'text-body-2' : ''">
|
|
||||||
Client Signature
|
|
||||||
<v-chip
|
|
||||||
v-if="signatureStatus"
|
|
||||||
:color="getSignatureStatusColor('client')"
|
|
||||||
size="x-small"
|
|
||||||
variant="tonal"
|
|
||||||
class="ml-2"
|
|
||||||
>
|
|
||||||
{{ getSignatureStatusText('client') }}
|
|
||||||
</v-chip>
|
|
||||||
</v-list-item-title>
|
|
||||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">{{ interest['Full Name'] }}</v-list-item-subtitle>
|
|
||||||
<template v-slot:append>
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-content-copy"
|
|
||||||
variant="text"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
@click="copyLink(interest['Signature Link Client'])"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-content-copy</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Copy signature link
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-open-in-new"
|
|
||||||
variant="text"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
@click="openLinkInNewTab(interest['Signature Link Client'])"
|
|
||||||
:disabled="!interest['Signature Link Client']"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-open-in-new</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Open in new tab
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item class="mb-2">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-avatar :color="getSignatureStatusColor('cc')" :size="mobile ? 32 : 40">
|
|
||||||
<v-icon :size="mobile ? 'small' : 'default'">
|
|
||||||
{{ getSignatureStatusIcon('cc') }}
|
|
||||||
</v-icon>
|
|
||||||
</v-avatar>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title :class="mobile ? 'text-body-2' : ''">
|
|
||||||
CC Signature
|
|
||||||
<v-chip
|
|
||||||
v-if="signatureStatus"
|
|
||||||
:color="getSignatureStatusColor('cc')"
|
|
||||||
size="x-small"
|
|
||||||
variant="tonal"
|
|
||||||
class="ml-2"
|
|
||||||
>
|
|
||||||
{{ getSignatureStatusText('cc') }}
|
|
||||||
</v-chip>
|
|
||||||
</v-list-item-title>
|
|
||||||
<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
|
|
||||||
icon="mdi-content-copy"
|
|
||||||
variant="text"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
@click="copyLink(interest['Signature Link CC'])"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-content-copy</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Copy signature link
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-open-in-new"
|
|
||||||
variant="text"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
@click="openLinkInNewTab(interest['Signature Link CC'])"
|
|
||||||
:disabled="!interest['Signature Link CC']"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-open-in-new</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Open in new tab
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-list-item>
|
|
||||||
|
|
||||||
<v-list-item class="mb-2">
|
|
||||||
<template v-slot:prepend>
|
|
||||||
<v-avatar :color="getSignatureStatusColor('developer')" :size="mobile ? 32 : 40">
|
|
||||||
<v-icon :size="mobile ? 'small' : 'default'">
|
|
||||||
{{ getSignatureStatusIcon('developer') }}
|
|
||||||
</v-icon>
|
|
||||||
</v-avatar>
|
|
||||||
</template>
|
|
||||||
<v-list-item-title :class="mobile ? 'text-body-2' : ''">
|
|
||||||
Developer Signature
|
|
||||||
<v-chip
|
|
||||||
v-if="signatureStatus"
|
|
||||||
:color="getSignatureStatusColor('developer')"
|
|
||||||
size="x-small"
|
|
||||||
variant="tonal"
|
|
||||||
class="ml-2"
|
|
||||||
>
|
|
||||||
{{ getSignatureStatusText('developer') }}
|
|
||||||
</v-chip>
|
|
||||||
</v-list-item-title>
|
|
||||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">David Mizrahi</v-list-item-subtitle>
|
|
||||||
<template v-slot:append>
|
|
||||||
<div class="d-flex gap-1">
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-content-copy"
|
|
||||||
variant="text"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
@click="copyLink(interest['Signature Link Developer'])"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-content-copy</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Copy signature link
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-open-in-new"
|
|
||||||
variant="text"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
@click="openLinkInNewTab(interest['Signature Link Developer'])"
|
|
||||||
:disabled="!interest['Signature Link Developer']"
|
|
||||||
>
|
|
||||||
<v-icon>mdi-open-in-new</v-icon>
|
|
||||||
<v-tooltip activator="parent" location="top">
|
|
||||||
Open in new tab
|
|
||||||
</v-tooltip>
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons Section -->
|
|
||||||
<div v-if="hasGeneratedEOI && !isEOISigned" class="mt-4 d-flex flex-wrap gap-2">
|
|
||||||
<v-btn
|
|
||||||
@click="generateEOI"
|
|
||||||
:loading="isGenerating"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
prepend-icon="mdi-refresh"
|
|
||||||
>
|
|
||||||
Regenerate EOI
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
@click="showDeleteGeneratedConfirmDialog = true"
|
|
||||||
color="error"
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
prepend-icon="mdi-delete"
|
|
||||||
:disabled="signatureStatus?.allSigned"
|
|
||||||
>
|
|
||||||
Delete Generated EOI
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Delete EOI Button - Only show if EOI is uploaded/signed -->
|
|
||||||
<div v-if="isEOISigned" class="mt-4">
|
|
||||||
<v-btn
|
|
||||||
@click="showDeleteConfirmDialog = true"
|
|
||||||
color="error"
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
prepend-icon="mdi-delete"
|
|
||||||
>
|
|
||||||
Delete Uploaded EOI
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<!-- Upload Dialog -->
|
|
||||||
<v-dialog
|
|
||||||
v-model="showUploadDialog"
|
|
||||||
:max-width="mobile ? '100%' : '600'"
|
|
||||||
:fullscreen="mobile"
|
|
||||||
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
|
|
||||||
>
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
<v-icon class="mr-2" color="primary">mdi-upload</v-icon>
|
|
||||||
Upload EOI Document
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn icon="mdi-close" variant="text" @click="closeUploadDialog"></v-btn>
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-card-text :class="mobile ? 'pa-4' : 'pa-6'">
|
|
||||||
<div class="text-center" :class="mobile ? 'mb-4' : 'mb-6'">
|
|
||||||
<v-icon :size="mobile ? 60 : 80" color="primary" class="mb-4">mdi-file-pdf-box</v-icon>
|
|
||||||
<div :class="mobile ? 'text-body-1 mb-2' : 'text-h6 mb-2'">Choose how to upload your EOI</div>
|
|
||||||
<div :class="mobile ? 'text-caption' : 'text-body-2'" class="text-grey">Upload a signed PDF document</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-file-input
|
|
||||||
v-model="selectedFile"
|
|
||||||
:label="mobile ? 'Select PDF file' : 'Drop your PDF here or click to browse'"
|
|
||||||
accept=".pdf"
|
|
||||||
prepend-icon=""
|
|
||||||
variant="outlined"
|
|
||||||
:density="mobile ? 'compact' : 'comfortable'"
|
|
||||||
:rules="[v => !!v || 'Please select a file']"
|
|
||||||
:show-size="!mobile"
|
|
||||||
class="mt-4"
|
|
||||||
>
|
|
||||||
<template v-slot:prepend-inner>
|
|
||||||
<v-icon color="primary" :size="mobile ? 'small' : 'default'">mdi-file-pdf-box</v-icon>
|
|
||||||
</template>
|
|
||||||
</v-file-input>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<v-card-actions class="pa-4">
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn @click="closeUploadDialog" variant="text" :size="mobile ? 'default' : 'large'">
|
|
||||||
Cancel
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
color="primary"
|
|
||||||
variant="flat"
|
|
||||||
@click="handleUpload"
|
|
||||||
:loading="isUploading"
|
|
||||||
:disabled="!selectedFile"
|
|
||||||
:size="mobile ? 'default' : 'large'"
|
|
||||||
:prepend-icon="!mobile ? 'mdi-upload' : undefined"
|
|
||||||
>
|
|
||||||
Upload{{ mobile ? '' : ' EOI' }}
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<!-- Delete Confirmation Dialog for Uploaded Documents -->
|
|
||||||
<v-dialog
|
|
||||||
v-model="showDeleteConfirmDialog"
|
|
||||||
:max-width="mobile ? '100%' : '400'"
|
|
||||||
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
|
|
||||||
>
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
<v-icon class="mr-2" color="error">mdi-alert</v-icon>
|
|
||||||
Confirm EOI Deletion
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
Are you sure you want to delete the uploaded EOI document? This will:
|
|
||||||
<ul class="mt-2">
|
|
||||||
<li>Remove the uploaded EOI document</li>
|
|
||||||
<li>Reset the Sales Process Level to "Specific Qualified Interest"</li>
|
|
||||||
<li>Reset the EOI Status to "Awaiting Further Details"</li>
|
|
||||||
<li>Allow a new EOI to be generated</li>
|
|
||||||
</ul>
|
|
||||||
<div class="mt-3 text-error">This action cannot be undone.</div>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-card-actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn
|
|
||||||
@click="showDeleteConfirmDialog = false"
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
color="error"
|
|
||||||
variant="flat"
|
|
||||||
@click="deleteUploadedEOI"
|
|
||||||
:loading="isDeleting"
|
|
||||||
>
|
|
||||||
Delete EOI
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
<!-- Slider Confirmation Dialog for Generated Documents -->
|
|
||||||
<SliderConfirmation
|
|
||||||
v-model="showDeleteGeneratedConfirmDialog"
|
|
||||||
title="Delete Generated EOI"
|
|
||||||
message="You are about to delete the generated EOI document. This will permanently remove the document from Documenso and reset all signature links."
|
|
||||||
icon="mdi-delete"
|
|
||||||
iconColor="error"
|
|
||||||
confirmButtonText="Delete EOI"
|
|
||||||
sliderText="Slide to delete generated EOI"
|
|
||||||
warningText="This action cannot be undone and will require regenerating the document."
|
|
||||||
:confirmationList="[
|
|
||||||
'Delete document from Documenso',
|
|
||||||
'Remove all signature links',
|
|
||||||
'Reset EOI status to \'Awaiting Further Details\'',
|
|
||||||
'Reset Sales Process Level to \'Specific Qualified Interest\''
|
|
||||||
]"
|
|
||||||
:loading="isDeletingGenerated"
|
|
||||||
@confirm="deleteGeneratedEOI"
|
|
||||||
@cancel="showDeleteGeneratedConfirmDialog = false"
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Interest } from '~/utils/types';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
interest: Interest;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'eoi-generated': [data: { signingLinks: Record<string, string> }];
|
|
||||||
'update': [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { mobile } = useDisplay();
|
|
||||||
const toast = useToast();
|
|
||||||
const isGenerating = ref(false);
|
|
||||||
const showUploadDialog = ref(false);
|
|
||||||
const isUploading = ref(false);
|
|
||||||
const selectedFile = ref<File | null>(null);
|
|
||||||
const showDeleteConfirmDialog = ref(false);
|
|
||||||
const showDeleteGeneratedConfirmDialog = ref(false);
|
|
||||||
const isDeleting = ref(false);
|
|
||||||
const isDeletingGenerated = ref(false);
|
|
||||||
const signatureStatus = ref<any>(null);
|
|
||||||
const isCheckingSignatures = ref(false);
|
|
||||||
|
|
||||||
const hasGeneratedEOI = computed(() => {
|
|
||||||
// Primary check: documensoID must exist for a generated EOI
|
|
||||||
// If documensoID is null/undefined, then there's no generated EOI regardless of signature links
|
|
||||||
|
|
||||||
// DEBUG: Log detailed information about the documensoID field
|
|
||||||
console.log('[EOISection] DEBUGGING hasGeneratedEOI computation:', {
|
|
||||||
interestId: props.interest.Id,
|
|
||||||
documensoID: props.interest['documensoID'],
|
|
||||||
documensoID_type: typeof props.interest['documensoID'],
|
|
||||||
documensoID_raw: JSON.stringify(props.interest['documensoID']),
|
|
||||||
documensoID_exists: 'documensoID' in props.interest,
|
|
||||||
documensoID_truthy: !!props.interest['documensoID'],
|
|
||||||
signature_links: {
|
|
||||||
client: props.interest['Signature Link Client'],
|
|
||||||
cc: props.interest['Signature Link CC'],
|
|
||||||
developer: props.interest['Signature Link Developer']
|
|
||||||
},
|
|
||||||
all_interest_keys: Object.keys(props.interest).filter(key => key.toLowerCase().includes('documen')),
|
|
||||||
result: !!props.interest['documensoID']
|
|
||||||
});
|
|
||||||
|
|
||||||
return !!(props.interest['documensoID']);
|
|
||||||
});
|
|
||||||
|
|
||||||
const eoiDocuments = computed(() => {
|
|
||||||
return props.interest['EOI Document'] || [];
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasEOIDocuments = computed(() => {
|
|
||||||
return eoiDocuments.value.length > 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
const isEOISigned = computed(() => {
|
|
||||||
return props.interest['EOI Status'] === 'Signed';
|
|
||||||
});
|
|
||||||
|
|
||||||
const generateEOI = async (retryCount = 0) => {
|
|
||||||
isGenerating.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $fetch<{
|
|
||||||
success: boolean;
|
|
||||||
documentId: string | number;
|
|
||||||
clientSigningUrl: string;
|
|
||||||
signingLinks: Record<string, string>;
|
|
||||||
}>('/api/email/generate-eoi-document', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
interestId: props.interest.Id.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success(response.documentId === 'existing'
|
|
||||||
? 'EOI already exists - signature links retrieved'
|
|
||||||
: 'EOI generated successfully');
|
|
||||||
|
|
||||||
emit('eoi-generated', { signingLinks: response.signingLinks });
|
|
||||||
emit('update'); // Trigger parent to refresh data
|
|
||||||
|
|
||||||
// Check signature status after generation
|
|
||||||
if (response.documentId !== 'existing') {
|
|
||||||
setTimeout(() => checkSignatureStatus(), 1000);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw new Error('EOI generation failed');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to generate EOI:', error);
|
|
||||||
|
|
||||||
// Retry logic
|
|
||||||
if (retryCount < 3) {
|
|
||||||
console.log(`Retrying EOI generation... Attempt ${retryCount + 2}/4`);
|
|
||||||
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
|
|
||||||
return generateEOI(retryCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show error message after all retries failed
|
|
||||||
toast.error(error.data?.statusMessage || error.message || 'Failed to generate EOI after multiple attempts');
|
|
||||||
} finally {
|
|
||||||
isGenerating.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkSignatureStatus = async () => {
|
|
||||||
if (!props.interest['documensoID']) return;
|
|
||||||
|
|
||||||
isCheckingSignatures.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $fetch<any>('/api/eoi/check-signature-status', {
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
interestId: props.interest.Id.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
signatureStatus.value = response;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to check signature status:', error);
|
|
||||||
} finally {
|
|
||||||
isCheckingSignatures.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSignatureStatusColor = (role: string) => {
|
|
||||||
if (!signatureStatus.value) return 'grey';
|
|
||||||
|
|
||||||
const isSigned = signatureStatus.value.signedRecipients?.some((r: any) => {
|
|
||||||
if (role === 'client') return r.email === props.interest['Email Address'];
|
|
||||||
if (role === 'cc') return r.email === 'sales@portnimara.com';
|
|
||||||
if (role === 'developer') return r.email === 'dm@portnimara.com';
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return isSigned ? 'success' : 'warning';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSignatureStatusIcon = (role: string) => {
|
|
||||||
if (!signatureStatus.value) return 'mdi-clock-outline';
|
|
||||||
|
|
||||||
const isSigned = signatureStatus.value.signedRecipients?.some((r: any) => {
|
|
||||||
if (role === 'client') return r.email === props.interest['Email Address'];
|
|
||||||
if (role === 'cc') return r.email === 'sales@portnimara.com';
|
|
||||||
if (role === 'developer') return r.email === 'dm@portnimara.com';
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return isSigned ? 'mdi-check' : 'mdi-clock-outline';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSignatureStatusText = (role: string) => {
|
|
||||||
if (!signatureStatus.value) return 'Checking...';
|
|
||||||
|
|
||||||
const isSigned = signatureStatus.value.signedRecipients?.some((r: any) => {
|
|
||||||
if (role === 'client') return r.email === props.interest['Email Address'];
|
|
||||||
if (role === 'cc') return r.email === 'sales@portnimara.com';
|
|
||||||
if (role === 'developer') return r.email === 'dm@portnimara.com';
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
return isSigned ? 'Signed' : 'Pending';
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyLink = async (link: string | undefined) => {
|
|
||||||
if (!link) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(link);
|
|
||||||
toast.success('Signature link copied to clipboard');
|
|
||||||
|
|
||||||
// Update EOI Time Sent if not already set
|
|
||||||
if (!props.interest['EOI Time Sent']) {
|
|
||||||
try {
|
|
||||||
await $fetch('/api/update-interest', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
id: props.interest.Id.toString(),
|
|
||||||
data: {
|
|
||||||
'EOI Time Sent': new Date().toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
emit('update'); // Refresh parent data
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to update EOI Time Sent:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.error('Failed to copy link');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openLinkInNewTab = (link: string | undefined) => {
|
|
||||||
if (!link) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
window.open(link, '_blank', 'noopener,noreferrer');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to open link:', error);
|
|
||||||
toast.error('Failed to open link in new tab');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
if (!dateString) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const date = new Date(dateString);
|
|
||||||
// Check if date is valid
|
|
||||||
if (isNaN(date.getTime())) {
|
|
||||||
return dateString; // Return original if parsing fails
|
|
||||||
}
|
|
||||||
|
|
||||||
return date.toLocaleString('en-GB', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: false
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Date formatting error:', error);
|
|
||||||
return dateString;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'Waiting for Signatures':
|
|
||||||
return 'warning';
|
|
||||||
case 'Signed':
|
|
||||||
return 'success';
|
|
||||||
default:
|
|
||||||
return 'grey';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDocument = async (index: number) => {
|
|
||||||
// For now, we'll just show a message since removing documents
|
|
||||||
// would require updating the database
|
|
||||||
toast.warning('Document removal is not yet implemented');
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadEOI = async (file: File) => {
|
|
||||||
isUploading.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('file', file);
|
|
||||||
|
|
||||||
const response = await $fetch<{ success: boolean; document: any; message: string }>('/api/eoi/upload-document', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
query: {
|
|
||||||
interestId: props.interest.Id.toString()
|
|
||||||
},
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success('EOI document uploaded successfully');
|
|
||||||
showUploadDialog.value = false;
|
|
||||||
selectedFile.value = null; // Reset file selection
|
|
||||||
emit('update'); // Refresh parent data
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to upload EOI:', error);
|
|
||||||
toast.error(error.data?.statusMessage || 'Failed to upload EOI document');
|
|
||||||
} finally {
|
|
||||||
isUploading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = () => {
|
|
||||||
if (selectedFile.value) {
|
|
||||||
uploadEOI(selectedFile.value);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeUploadDialog = () => {
|
|
||||||
showUploadDialog.value = false;
|
|
||||||
selectedFile.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteUploadedEOI = async () => {
|
|
||||||
isDeleting.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $fetch<{ success: boolean; message: string }>('/api/eoi/delete-document', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
interestId: props.interest.Id.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success('Uploaded EOI deleted successfully');
|
|
||||||
showDeleteConfirmDialog.value = false;
|
|
||||||
emit('update'); // Refresh parent data
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to delete uploaded EOI:', error);
|
|
||||||
toast.error(error.data?.statusMessage || 'Failed to delete uploaded EOI');
|
|
||||||
} finally {
|
|
||||||
isDeleting.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteGeneratedEOI = async () => {
|
const deleteGeneratedEOI = async () => {
|
||||||
|
// Prevent multiple simultaneous deletion attempts
|
||||||
|
if (isDeletingGenerated.value) {
|
||||||
|
console.log('Delete already in progress, ignoring additional clicks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
isDeletingGenerated.value = true;
|
isDeletingGenerated.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -802,14 +28,9 @@ const deleteGeneratedEOI = async () => {
|
||||||
console.error('Failed to delete generated EOI:', error);
|
console.error('Failed to delete generated EOI:', error);
|
||||||
toast.error(error.data?.statusMessage || 'Failed to delete generated EOI');
|
toast.error(error.data?.statusMessage || 'Failed to delete generated EOI');
|
||||||
} finally {
|
} finally {
|
||||||
|
// Add a small delay before allowing another deletion attempt
|
||||||
|
setTimeout(() => {
|
||||||
isDeletingGenerated.value = false;
|
isDeletingGenerated.value = false;
|
||||||
|
}, 1000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-check signature status when component mounts if there's a generated EOI
|
|
||||||
onMounted(() => {
|
|
||||||
if (hasGeneratedEOI.value && !isEOISigned.value) {
|
|
||||||
checkSignatureStatus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
|
||||||
|
|
@ -407,46 +407,7 @@
|
||||||
prepend-inner-icon="mdi-tag"
|
prepend-inner-icon="mdi-tag"
|
||||||
></v-select>
|
></v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="6">
|
<v-col cols="12" md="12">
|
||||||
<v-autocomplete
|
|
||||||
v-model="selectedBerthRecommendations"
|
|
||||||
:items="groupedBerths"
|
|
||||||
:item-title="(item) => item.isDivider ? '' : item['Mooring Number']"
|
|
||||||
:item-value="(item) => item.isDivider ? null : item.Id"
|
|
||||||
label="Berth Recommendations"
|
|
||||||
variant="outlined"
|
|
||||||
density="comfortable"
|
|
||||||
multiple
|
|
||||||
chips
|
|
||||||
closable-chips
|
|
||||||
:loading="loadingBerths"
|
|
||||||
prepend-inner-icon="mdi-star"
|
|
||||||
@update:model-value="updateBerthRecommendations"
|
|
||||||
>
|
|
||||||
<template v-slot:item="{ props, item }">
|
|
||||||
<v-divider v-if="item.raw.isDivider" class="mt-2 mb-2">
|
|
||||||
<template v-slot:default>
|
|
||||||
<div class="text-caption text-medium-emphasis px-2">
|
|
||||||
{{ item.raw.letter }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-divider>
|
|
||||||
<v-list-item
|
|
||||||
v-else
|
|
||||||
v-bind="props"
|
|
||||||
:title="item.raw['Mooring Number']"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-slot:chip="{ props, item }">
|
|
||||||
<v-chip
|
|
||||||
v-bind="props"
|
|
||||||
:text="item.raw['Mooring Number']"
|
|
||||||
size="small"
|
|
||||||
></v-chip>
|
|
||||||
</template>
|
|
||||||
</v-autocomplete>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selectedBerths"
|
v-model="selectedBerths"
|
||||||
:items="groupedBerths"
|
:items="groupedBerths"
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ interface Country {
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: string;
|
modelValue?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
variant?: 'flat' | 'elevated' | 'tonal' | 'outlined' | 'plain' | 'underlined' | 'solo' | 'solo-inverted' | 'solo-filled';
|
variant?: 'outlined' | 'plain' | 'underlined' | 'solo' | 'solo-inverted' | 'solo-filled' | 'filled';
|
||||||
density?: 'default' | 'comfortable' | 'compact';
|
density?: 'default' | 'comfortable' | 'compact';
|
||||||
rules?: any[];
|
rules?: any[];
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
@ -392,11 +392,21 @@ const displayValue = computed(() => localNumber.value);
|
||||||
// Parse initial value
|
// Parse initial value
|
||||||
watch(() => props.modelValue, (newValue) => {
|
watch(() => props.modelValue, (newValue) => {
|
||||||
if (newValue) {
|
if (newValue) {
|
||||||
// Find matching country code
|
// Find matching country code, but prefer current selection if it matches
|
||||||
const country = countries.find(c => newValue.startsWith(c.dialCode));
|
const matchingCountries = countries.filter(c => newValue.startsWith(c.dialCode));
|
||||||
if (country) {
|
if (matchingCountries.length > 0) {
|
||||||
selectedCountry.value = country;
|
// If current country is in the matching list, keep it
|
||||||
localNumber.value = newValue.slice(country.dialCode.length);
|
const currentMatches = matchingCountries.find(c => c.iso2 === selectedCountry.value.iso2);
|
||||||
|
if (currentMatches) {
|
||||||
|
// Keep current country
|
||||||
|
localNumber.value = newValue.slice(selectedCountry.value.dialCode.length);
|
||||||
|
} else {
|
||||||
|
// Prefer default country if it matches, otherwise use the first match
|
||||||
|
const defaultCountry = matchingCountries.find(c => c.iso2 === props.defaultCountry);
|
||||||
|
const selectedNewCountry = defaultCountry || matchingCountries[0];
|
||||||
|
selectedCountry.value = selectedNewCountry;
|
||||||
|
localNumber.value = newValue.slice(selectedNewCountry.dialCode.length);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Default to current country if no match
|
// Default to current country if no match
|
||||||
localNumber.value = newValue;
|
localNumber.value = newValue;
|
||||||
|
|
|
||||||
|
|
@ -142,7 +142,10 @@ const startSliding = (event: MouseEvent | TouchEvent) => {
|
||||||
|
|
||||||
const rect = sliderTrack.value.getBoundingClientRect();
|
const rect = sliderTrack.value.getBoundingClientRect();
|
||||||
const clientX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
|
const clientX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
|
||||||
const progress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
const handleWidth = 50; // Handle width in pixels
|
||||||
|
const availableWidth = rect.width - handleWidth;
|
||||||
|
const relativeX = clientX - rect.left;
|
||||||
|
const progress = Math.max(0, Math.min(100, (relativeX / availableWidth) * 100));
|
||||||
|
|
||||||
sliderProgress.value = progress;
|
sliderProgress.value = progress;
|
||||||
};
|
};
|
||||||
|
|
@ -226,7 +229,7 @@ watch(showDialog, (newValue) => {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
transform: translateX(-50%);
|
transform: translateX(0);
|
||||||
transition: none;
|
transition: none;
|
||||||
will-change: transform, left;
|
will-change: transform, left;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import Imap from 'imap';
|
||||||
import { simpleParser } from 'mailparser';
|
import { simpleParser } from 'mailparser';
|
||||||
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
import { getCredentialsFromSession, decryptCredentials } from '~/server/utils/encryption';
|
||||||
import { listFiles, getFileStats, getMinioClient, uploadFile } from '~/server/utils/minio';
|
import { listFiles, getFileStats, getMinioClient, uploadFile } from '~/server/utils/minio';
|
||||||
|
import { getIMAPPool } from '~/server/utils/imap-pool';
|
||||||
|
|
||||||
interface EmailMessage {
|
interface EmailMessage {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -13,6 +14,7 @@ interface EmailMessage {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
direction: 'sent' | 'received';
|
direction: 'sent' | 'received';
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
|
attachments?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -144,7 +146,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
imapEmails = await Promise.race([
|
imapEmails = await Promise.race([
|
||||||
fetchImapEmails(imapConfig, userEmail, clientEmail, limit, interestId),
|
fetchImapEmailsWithPool(sessionId, userEmail, clientEmail, limit, interestId),
|
||||||
timeoutPromise
|
timeoutPromise
|
||||||
]);
|
]);
|
||||||
} catch (imapError) {
|
} catch (imapError) {
|
||||||
|
|
@ -187,7 +189,217 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Separate function for IMAP fetching with proper cleanup
|
// Function for IMAP fetching using connection pool
|
||||||
|
async function fetchImapEmailsWithPool(
|
||||||
|
sessionId: string,
|
||||||
|
userEmail: string,
|
||||||
|
clientEmail: string,
|
||||||
|
limit: number,
|
||||||
|
interestId?: string
|
||||||
|
): Promise<EmailMessage[]> {
|
||||||
|
const pool = getIMAPPool();
|
||||||
|
const imap = await pool.getConnection(sessionId);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const allEmails: EmailMessage[] = [];
|
||||||
|
|
||||||
|
// Search in both INBOX and Sent folders
|
||||||
|
const foldersToSearch = ['INBOX', 'Sent', 'Sent Items', 'Sent Mail'];
|
||||||
|
let currentFolderIndex = 0;
|
||||||
|
|
||||||
|
const searchNextFolder = () => {
|
||||||
|
if (currentFolderIndex >= foldersToSearch.length) {
|
||||||
|
resolve(allEmails);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderName = foldersToSearch[currentFolderIndex];
|
||||||
|
currentFolderIndex++;
|
||||||
|
|
||||||
|
imap.openBox(folderName, true, (err: any, box: any) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(`[IMAPPool] Folder ${folderName} not found, trying next...`);
|
||||||
|
searchNextFolder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IMAPPool] Searching in folder: ${folderName}`);
|
||||||
|
|
||||||
|
if (!clientEmail || clientEmail.trim() === '') {
|
||||||
|
console.log('[IMAPPool] No client email provided, skipping search');
|
||||||
|
searchNextFolder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imap.search(['ALL'], (err: any, results: number[]) => {
|
||||||
|
if (err) {
|
||||||
|
console.error(`[IMAPPool] Search error in ${folderName}:`, err);
|
||||||
|
searchNextFolder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!results || results.length === 0) {
|
||||||
|
console.log(`[IMAPPool] No emails found in ${folderName}`);
|
||||||
|
searchNextFolder();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[IMAPPool] Found ${results.length} emails in ${folderName}`);
|
||||||
|
const messagesToFetch = results.slice(-limit);
|
||||||
|
let messagesProcessed = 0;
|
||||||
|
|
||||||
|
const fetch = imap.fetch(messagesToFetch, {
|
||||||
|
bodies: '',
|
||||||
|
struct: true,
|
||||||
|
envelope: true
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.on('message', (msg: any, seqno: number) => {
|
||||||
|
msg.on('body', (stream: any, info: any) => {
|
||||||
|
simpleParser(stream as any, async (err: any, parsed: any) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('[IMAPPool] Parse error:', err);
|
||||||
|
messagesProcessed++;
|
||||||
|
if (messagesProcessed === messagesToFetch.length) {
|
||||||
|
searchNextFolder();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this email involves the client
|
||||||
|
const fromEmail = parsed.from?.text || '';
|
||||||
|
const toEmails = Array.isArray(parsed.to)
|
||||||
|
? parsed.to.map((addr: any) => addr.text).join(', ')
|
||||||
|
: parsed.to?.text || '';
|
||||||
|
const ccEmails = Array.isArray(parsed.cc)
|
||||||
|
? parsed.cc.map((addr: any) => addr.text).join(', ')
|
||||||
|
: parsed.cc?.text || '';
|
||||||
|
|
||||||
|
// Filter to only include emails to/from the client
|
||||||
|
const involvesClient =
|
||||||
|
fromEmail.toLowerCase().includes(clientEmail.toLowerCase()) ||
|
||||||
|
toEmails.toLowerCase().includes(clientEmail.toLowerCase()) ||
|
||||||
|
ccEmails.toLowerCase().includes(clientEmail.toLowerCase());
|
||||||
|
|
||||||
|
if (!involvesClient) {
|
||||||
|
messagesProcessed++;
|
||||||
|
if (messagesProcessed === messagesToFetch.length) {
|
||||||
|
searchNextFolder();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process attachments
|
||||||
|
const attachments: any[] = [];
|
||||||
|
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||||
|
for (const attachment of parsed.attachments) {
|
||||||
|
try {
|
||||||
|
// Save attachment to MinIO
|
||||||
|
const attachmentId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const fileName = attachment.filename || `attachment-${attachmentId}`;
|
||||||
|
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||||
|
const objectName = `interest-${interestId}/attachments/${attachmentId}-${sanitizedFileName}`;
|
||||||
|
|
||||||
|
const client = getMinioClient();
|
||||||
|
await client.putObject('client-emails', objectName, attachment.content, attachment.content?.length || 0, {
|
||||||
|
'Content-Type': attachment.contentType || 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${sanitizedFileName}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
attachments.push({
|
||||||
|
id: attachmentId,
|
||||||
|
filename: sanitizedFileName,
|
||||||
|
originalName: attachment.filename,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
size: attachment.content?.length || attachment.size || 0,
|
||||||
|
path: objectName,
|
||||||
|
bucket: 'client-emails'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[IMAPPool] Saved attachment: ${sanitizedFileName} (${attachment.content?.length || 0} bytes)`);
|
||||||
|
} catch (attachmentError) {
|
||||||
|
console.error('[IMAPPool] Failed to save attachment:', attachment.filename, attachmentError);
|
||||||
|
// Still include attachment info even if save failed
|
||||||
|
attachments.push({
|
||||||
|
filename: attachment.filename || 'Unknown',
|
||||||
|
originalName: attachment.filename,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
size: attachment.content?.length || attachment.size || 0,
|
||||||
|
error: 'Failed to save'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const email: EmailMessage = {
|
||||||
|
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
||||||
|
from: fromEmail,
|
||||||
|
to: toEmails,
|
||||||
|
subject: parsed.subject || '',
|
||||||
|
body: parsed.text || '',
|
||||||
|
html: parsed.html || undefined,
|
||||||
|
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
||||||
|
direction: fromEmail.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received',
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
if (parsed.headers.has('in-reply-to')) {
|
||||||
|
email.threadId = parsed.headers.get('in-reply-to') as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
allEmails.push(email);
|
||||||
|
|
||||||
|
// Cache this email if we have an interestId
|
||||||
|
if (interestId && involvesClient) {
|
||||||
|
try {
|
||||||
|
const emailData = {
|
||||||
|
...email,
|
||||||
|
interestId: interestId
|
||||||
|
};
|
||||||
|
|
||||||
|
const objectName = `interest-${interestId}/${Date.now()}-${email.direction}.json`;
|
||||||
|
const buffer = Buffer.from(JSON.stringify(emailData, null, 2));
|
||||||
|
|
||||||
|
// Upload to the client-emails bucket
|
||||||
|
const client = getMinioClient();
|
||||||
|
client.putObject('client-emails', objectName, buffer, buffer.length, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[IMAPPool] Failed to cache email:', err);
|
||||||
|
});
|
||||||
|
} catch (cacheError) {
|
||||||
|
console.error('[IMAPPool] Failed to cache email:', cacheError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messagesProcessed++;
|
||||||
|
|
||||||
|
if (messagesProcessed === messagesToFetch.length) {
|
||||||
|
searchNextFolder();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.once('error', (err: any) => {
|
||||||
|
console.error('[IMAPPool] Fetch error:', err);
|
||||||
|
searchNextFolder();
|
||||||
|
});
|
||||||
|
|
||||||
|
fetch.once('end', () => {
|
||||||
|
if (messagesProcessed === 0) {
|
||||||
|
searchNextFolder();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
searchNextFolder();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate function for IMAP fetching with proper cleanup (legacy)
|
||||||
async function fetchImapEmails(
|
async function fetchImapEmails(
|
||||||
imapConfig: any,
|
imapConfig: any,
|
||||||
userEmail: string,
|
userEmail: string,
|
||||||
|
|
@ -304,6 +516,48 @@ async function fetchImapEmails(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process attachments
|
||||||
|
const attachments: any[] = [];
|
||||||
|
if (parsed.attachments && parsed.attachments.length > 0) {
|
||||||
|
for (const attachment of parsed.attachments) {
|
||||||
|
try {
|
||||||
|
// Save attachment to MinIO
|
||||||
|
const attachmentId = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const fileName = attachment.filename || `attachment-${attachmentId}`;
|
||||||
|
const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
|
||||||
|
const objectName = `interest-${interestId}/attachments/${attachmentId}-${sanitizedFileName}`;
|
||||||
|
|
||||||
|
const client = getMinioClient();
|
||||||
|
await client.putObject('client-emails', objectName, attachment.content, attachment.content?.length || 0, {
|
||||||
|
'Content-Type': attachment.contentType || 'application/octet-stream',
|
||||||
|
'Content-Disposition': `attachment; filename="${sanitizedFileName}"`
|
||||||
|
});
|
||||||
|
|
||||||
|
attachments.push({
|
||||||
|
id: attachmentId,
|
||||||
|
filename: sanitizedFileName,
|
||||||
|
originalName: attachment.filename,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
size: attachment.content?.length || attachment.size || 0,
|
||||||
|
path: objectName,
|
||||||
|
bucket: 'client-emails'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Saved attachment: ${sanitizedFileName} (${attachment.content?.length || 0} bytes)`);
|
||||||
|
} catch (attachmentError) {
|
||||||
|
console.error('Failed to save attachment:', attachment.filename, attachmentError);
|
||||||
|
// Still include attachment info even if save failed
|
||||||
|
attachments.push({
|
||||||
|
filename: attachment.filename || 'Unknown',
|
||||||
|
originalName: attachment.filename,
|
||||||
|
contentType: attachment.contentType,
|
||||||
|
size: attachment.content?.length || attachment.size || 0,
|
||||||
|
error: 'Failed to save'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const email: EmailMessage = {
|
const email: EmailMessage = {
|
||||||
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
|
|
@ -312,7 +566,8 @@ async function fetchImapEmails(
|
||||||
body: parsed.text || '',
|
body: parsed.text || '',
|
||||||
html: parsed.html || undefined,
|
html: parsed.html || undefined,
|
||||||
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
||||||
direction: fromEmail.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received'
|
direction: fromEmail.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received',
|
||||||
|
attachments: attachments.length > 0 ? attachments : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parsed.headers.has('in-reply-to')) {
|
if (parsed.headers.has('in-reply-to')) {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,38 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('[get-berths] Successfully fetched berths, count:', berths.list?.length || 0);
|
console.log('[get-berths] Successfully fetched berths, count:', berths.list?.length || 0);
|
||||||
|
|
||||||
|
// Sort berths by letter zone and then by number
|
||||||
|
if (berths.list && Array.isArray(berths.list)) {
|
||||||
|
berths.list.sort((a, b) => {
|
||||||
|
const berthA = a['Berth Number'] || '';
|
||||||
|
const berthB = b['Berth Number'] || '';
|
||||||
|
|
||||||
|
// Extract letter and number parts
|
||||||
|
const matchA = berthA.match(/^([A-Za-z]+)(\d+)$/);
|
||||||
|
const matchB = berthB.match(/^([A-Za-z]+)(\d+)$/);
|
||||||
|
|
||||||
|
if (matchA && matchB) {
|
||||||
|
const [, letterA, numberA] = matchA;
|
||||||
|
const [, letterB, numberB] = matchB;
|
||||||
|
|
||||||
|
// First sort by letter zone
|
||||||
|
const letterCompare = letterA.localeCompare(letterB);
|
||||||
|
if (letterCompare !== 0) {
|
||||||
|
return letterCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then sort by number within the same letter zone
|
||||||
|
return parseInt(numberA) - parseInt(numberB);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string comparison if pattern doesn't match
|
||||||
|
return berthA.localeCompare(berthB);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[get-berths] Berths sorted by zone and number');
|
||||||
|
}
|
||||||
|
|
||||||
return berths;
|
return berths;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[get-berths] Error occurred:', error);
|
console.error('[get-berths] Error occurred:', error);
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { withBerthQueue } from '~/server/utils/operation-lock';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||||
console.log('[link-berths] Request received with x-tag:', xTagHeader);
|
console.log('[link-berths] Request received with x-tag:', xTagHeader);
|
||||||
|
|
@ -19,6 +21,8 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use queuing system to handle rapid berth selections gracefully
|
||||||
|
return await withBerthQueue(interestId, async () => {
|
||||||
const config = getNocoDbConfiguration();
|
const config = getNocoDbConfiguration();
|
||||||
const interestsTableId = "mbs9hjauug4eseo";
|
const interestsTableId = "mbs9hjauug4eseo";
|
||||||
const berthsLinkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
|
const berthsLinkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
|
||||||
|
|
@ -40,6 +44,8 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
console.log('[link-berths] Successfully linked berths to interest:', interestId);
|
console.log('[link-berths] Successfully linked berths to interest:', interestId);
|
||||||
return result;
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[link-berths] Error occurred:', error);
|
console.error('[link-berths] Error occurred:', error);
|
||||||
console.error('[link-berths] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
console.error('[link-berths] Error details:', error instanceof Error ? error.message : 'Unknown error');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { withBerthQueue } from '~/server/utils/operation-lock';
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||||
console.log('[unlink-berths] Request received with x-tag:', xTagHeader);
|
console.log('[unlink-berths] Request received with x-tag:', xTagHeader);
|
||||||
|
|
@ -17,6 +19,8 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use queuing system to handle rapid berth operations gracefully
|
||||||
|
return await withBerthQueue(interestId, async () => {
|
||||||
const config = getNocoDbConfiguration();
|
const config = getNocoDbConfiguration();
|
||||||
const interestsTableId = "mbs9hjauug4eseo";
|
const interestsTableId = "mbs9hjauug4eseo";
|
||||||
const berthsLinkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
|
const berthsLinkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
|
||||||
|
|
@ -35,5 +39,7 @@ export default defineEventHandler(async (event) => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log('[unlink-berths] Successfully unlinked berths from interest:', interestId);
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,227 @@
|
||||||
|
import Imap from 'imap';
|
||||||
|
import { getCredentialsFromSession, decryptCredentials } from './encryption';
|
||||||
|
|
||||||
|
interface ConnectionPoolItem {
|
||||||
|
connection: any;
|
||||||
|
sessionId: string;
|
||||||
|
lastUsed: number;
|
||||||
|
isConnected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IMAPConnectionPool {
|
||||||
|
private connections: Map<string, ConnectionPoolItem> = new Map();
|
||||||
|
private maxConnections = 5;
|
||||||
|
private connectionTimeout = 300000; // 5 minutes
|
||||||
|
private reconnectAttempts = 3;
|
||||||
|
private reconnectDelay = 1000; // 1 second
|
||||||
|
|
||||||
|
private cleanupInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Cleanup expired connections every minute
|
||||||
|
this.cleanupInterval = setInterval(() => {
|
||||||
|
this.cleanupExpiredConnections();
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConnection(sessionId: string): Promise<any> {
|
||||||
|
// Check if we have an existing connection
|
||||||
|
const existing = this.connections.get(sessionId);
|
||||||
|
if (existing && existing.isConnected) {
|
||||||
|
// Test connection health
|
||||||
|
if (await this.testConnection(existing.connection)) {
|
||||||
|
existing.lastUsed = Date.now();
|
||||||
|
return existing.connection;
|
||||||
|
} else {
|
||||||
|
// Connection is dead, remove it
|
||||||
|
this.removeConnection(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new connection
|
||||||
|
return await this.createConnection(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createConnection(sessionId: string): Promise<any> {
|
||||||
|
const encryptedCredentials = getCredentialsFromSession(sessionId);
|
||||||
|
if (!encryptedCredentials) {
|
||||||
|
throw new Error('No credentials found for session');
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials;
|
||||||
|
try {
|
||||||
|
credentials = decryptCredentials(encryptedCredentials);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Failed to decrypt credentials');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imapConfig = {
|
||||||
|
user: credentials.email,
|
||||||
|
password: credentials.password,
|
||||||
|
host: process.env.NUXT_EMAIL_IMAP_HOST || 'mail.portnimara.com',
|
||||||
|
port: parseInt(process.env.NUXT_EMAIL_IMAP_PORT || '993'),
|
||||||
|
tls: true,
|
||||||
|
tlsOptions: {
|
||||||
|
rejectUnauthorized: false
|
||||||
|
},
|
||||||
|
connTimeout: 15000, // 15 seconds connection timeout
|
||||||
|
authTimeout: 10000, // 10 seconds auth timeout
|
||||||
|
keepalive: {
|
||||||
|
interval: 10000, // Send keepalive every 10 seconds
|
||||||
|
idleInterval: 300000, // IDLE for 5 minutes
|
||||||
|
forceNoop: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const imap = new Imap(imapConfig);
|
||||||
|
let connectionAttempts = 0;
|
||||||
|
|
||||||
|
const attemptConnection = () => {
|
||||||
|
connectionAttempts++;
|
||||||
|
|
||||||
|
imap.once('ready', () => {
|
||||||
|
console.log(`[IMAPPool] Connection established for session ${sessionId}`);
|
||||||
|
|
||||||
|
// Store connection
|
||||||
|
this.connections.set(sessionId, {
|
||||||
|
connection: imap,
|
||||||
|
sessionId,
|
||||||
|
lastUsed: Date.now(),
|
||||||
|
isConnected: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up error handlers
|
||||||
|
imap.on('error', (err: any) => {
|
||||||
|
console.error(`[IMAPPool] Connection error for session ${sessionId}:`, err);
|
||||||
|
this.markConnectionAsDead(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.on('end', () => {
|
||||||
|
console.log(`[IMAPPool] Connection ended for session ${sessionId}`);
|
||||||
|
this.removeConnection(sessionId);
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(imap);
|
||||||
|
});
|
||||||
|
|
||||||
|
imap.once('error', (err: any) => {
|
||||||
|
console.error(`[IMAPPool] Connection attempt ${connectionAttempts} failed:`, err);
|
||||||
|
|
||||||
|
if (connectionAttempts < this.reconnectAttempts) {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
imap.connect();
|
||||||
|
} catch (connectError) {
|
||||||
|
console.error(`[IMAPPool] Reconnect attempt failed:`, connectError);
|
||||||
|
attemptConnection();
|
||||||
|
}
|
||||||
|
}, this.reconnectDelay * connectionAttempts);
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
imap.connect();
|
||||||
|
} catch (connectError) {
|
||||||
|
console.error(`[IMAPPool] Initial connect failed:`, connectError);
|
||||||
|
if (connectionAttempts < this.reconnectAttempts) {
|
||||||
|
setTimeout(attemptConnection, this.reconnectDelay);
|
||||||
|
} else {
|
||||||
|
reject(connectError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptConnection();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async testConnection(imap: any): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
if (!imap || imap.state !== 'authenticated') {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with a simple NOOP command
|
||||||
|
imap.seq.fetch('1:1', { bodies: 'HEADER' }, (err: any) => {
|
||||||
|
// Even if fetch fails, if no connection error then connection is likely OK
|
||||||
|
resolve(!err || err.code !== 'ECONNRESET');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout the test after 5 seconds
|
||||||
|
setTimeout(() => resolve(false), 5000);
|
||||||
|
} catch (error) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private markConnectionAsDead(sessionId: string): void {
|
||||||
|
const connection = this.connections.get(sessionId);
|
||||||
|
if (connection) {
|
||||||
|
connection.isConnected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeConnection(sessionId: string): void {
|
||||||
|
const connection = this.connections.get(sessionId);
|
||||||
|
if (connection) {
|
||||||
|
try {
|
||||||
|
if (connection.connection && typeof connection.connection.end === 'function') {
|
||||||
|
connection.connection.end();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[IMAPPool] Error closing connection:`, error);
|
||||||
|
}
|
||||||
|
this.connections.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupExpiredConnections(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, connection] of this.connections.entries()) {
|
||||||
|
if (now - connection.lastUsed > this.connectionTimeout) {
|
||||||
|
console.log(`[IMAPPool] Cleaning up expired connection for session ${sessionId}`);
|
||||||
|
this.removeConnection(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeConnection(sessionId: string): Promise<void> {
|
||||||
|
this.removeConnection(sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async closeAllConnections(): Promise<void> {
|
||||||
|
for (const sessionId of this.connections.keys()) {
|
||||||
|
this.removeConnection(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy(): void {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
}
|
||||||
|
this.closeAllConnections();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
let globalIMAPPool: IMAPConnectionPool | null = null;
|
||||||
|
|
||||||
|
export function getIMAPPool(): IMAPConnectionPool {
|
||||||
|
if (!globalIMAPPool) {
|
||||||
|
globalIMAPPool = new IMAPConnectionPool();
|
||||||
|
}
|
||||||
|
return globalIMAPPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function destroyIMAPPool(): void {
|
||||||
|
if (globalIMAPPool) {
|
||||||
|
globalIMAPPool.destroy();
|
||||||
|
globalIMAPPool = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
interface QueuedOperation<T> {
|
||||||
|
operation: () => Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: any) => void;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queuing system for graceful handling of rapid operations
|
||||||
|
class OperationQueue {
|
||||||
|
private queues: Map<string, QueuedOperation<any>[]> = new Map();
|
||||||
|
private processing: Map<string, boolean> = new Map();
|
||||||
|
private maxQueueSize = 10;
|
||||||
|
private batchDelay = 100; // 100ms delay for batching rapid operations
|
||||||
|
|
||||||
|
async enqueue<T>(queueKey: string, operation: () => Promise<T>): Promise<T> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Get or create queue for this key
|
||||||
|
let queue = this.queues.get(queueKey);
|
||||||
|
if (!queue) {
|
||||||
|
queue = [];
|
||||||
|
this.queues.set(queueKey, queue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check queue size limit
|
||||||
|
if (queue.length >= this.maxQueueSize) {
|
||||||
|
reject(new Error(`Queue for ${queueKey} is full`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add operation to queue
|
||||||
|
queue.push({
|
||||||
|
operation,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start processing if not already processing
|
||||||
|
if (!this.processing.get(queueKey)) {
|
||||||
|
this.processQueue(queueKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processQueue(queueKey: string): Promise<void> {
|
||||||
|
this.processing.set(queueKey, true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queue = this.queues.get(queueKey);
|
||||||
|
if (!queue || queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For berth operations, we can batch rapid selections
|
||||||
|
if (queueKey.startsWith('berth-')) {
|
||||||
|
await this.processBerthQueue(queueKey, queue);
|
||||||
|
} else {
|
||||||
|
// For other operations, process one by one
|
||||||
|
await this.processSequentialQueue(queueKey, queue);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.processing.set(queueKey, false);
|
||||||
|
|
||||||
|
// Check if more operations were added during processing
|
||||||
|
const queue = this.queues.get(queueKey);
|
||||||
|
if (queue && queue.length > 0) {
|
||||||
|
// Delay slightly before processing next batch
|
||||||
|
setTimeout(() => this.processQueue(queueKey), this.batchDelay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processBerthQueue(queueKey: string, queue: QueuedOperation<any>[]): Promise<void> {
|
||||||
|
// Wait a short time to collect rapid selections
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this.batchDelay));
|
||||||
|
|
||||||
|
// Get all pending operations
|
||||||
|
const operations = [...queue];
|
||||||
|
queue.length = 0; // Clear the queue
|
||||||
|
|
||||||
|
if (operations.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`[OperationQueue] Processing ${operations.length} berth operations for ${queueKey}`);
|
||||||
|
|
||||||
|
// Process operations in order, but allow them to execute
|
||||||
|
for (const { operation, resolve, reject } of operations) {
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[OperationQueue] Operation failed for ${queueKey}:`, error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processSequentialQueue(queueKey: string, queue: QueuedOperation<any>[]): Promise<void> {
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const { operation, resolve, reject } = queue.shift()!;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await operation();
|
||||||
|
resolve(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[OperationQueue] Operation failed for ${queueKey}:`, error);
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up old queues
|
||||||
|
cleanup(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
const maxAge = 300000; // 5 minutes
|
||||||
|
|
||||||
|
for (const [queueKey, queue] of this.queues.entries()) {
|
||||||
|
// Remove operations older than maxAge
|
||||||
|
for (let i = queue.length - 1; i >= 0; i--) {
|
||||||
|
if (now - queue[i].timestamp > maxAge) {
|
||||||
|
const { reject } = queue[i];
|
||||||
|
reject(new Error('Operation timeout'));
|
||||||
|
queue.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove empty queues
|
||||||
|
if (queue.length === 0) {
|
||||||
|
this.queues.delete(queueKey);
|
||||||
|
this.processing.delete(queueKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global instance
|
||||||
|
let globalOperationQueue: OperationQueue | null = null;
|
||||||
|
|
||||||
|
export function getOperationQueue(): OperationQueue {
|
||||||
|
if (!globalOperationQueue) {
|
||||||
|
globalOperationQueue = new OperationQueue();
|
||||||
|
|
||||||
|
// Clean up expired queues every minute
|
||||||
|
setInterval(() => {
|
||||||
|
globalOperationQueue?.cleanup();
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
return globalOperationQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for berth operations (allows rapid selection queuing)
|
||||||
|
export async function withBerthQueue<T>(interestId: string, operation: () => Promise<T>): Promise<T> {
|
||||||
|
const queue = getOperationQueue();
|
||||||
|
return queue.enqueue(`berth-${interestId}`, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function for EOI operations
|
||||||
|
export async function withEOIQueue<T>(interestId: string, operation: () => Promise<T>): Promise<T> {
|
||||||
|
const queue = getOperationQueue();
|
||||||
|
return queue.enqueue(`eoi-${interestId}`, operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy function names for backward compatibility
|
||||||
|
export const withBerthLock = withBerthQueue;
|
||||||
|
export const withEOILock = withEOIQueue;
|
||||||
Loading…
Reference in New Issue