Add EOI automation system with email processing and document management
- Implement automated EOI processing from sales emails - Add EOI document upload and management capabilities - Enhance email thread handling with better parsing and grouping - Add retry logic and error handling for file operations - Introduce Documeso integration for document processing - Create server tasks and plugins infrastructure - Update email composer with improved attachment handling
This commit is contained in:
parent
5c30411c2b
commit
218705da52
|
|
@ -0,0 +1,425 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-card-title class="text-h6 d-flex align-center pb-4">
|
||||
<v-icon class="mr-2" color="primary">mdi-email</v-icon>
|
||||
Email Communication
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0">
|
||||
|
||||
<!-- Compose New Email -->
|
||||
<div class="mb-4">
|
||||
<v-btn
|
||||
@click="showComposer = true"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-email-edit"
|
||||
>
|
||||
Compose Email
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Email Thread List -->
|
||||
<div v-if="emailThreads.length > 0" class="email-threads">
|
||||
<div class="text-subtitle-1 mb-3">Email History</div>
|
||||
<v-timeline side="end" density="comfortable">
|
||||
<v-timeline-item
|
||||
v-for="(email, index) in emailThreads"
|
||||
:key="index"
|
||||
:dot-color="email.direction === 'sent' ? 'primary' : 'success'"
|
||||
:icon="email.direction === 'sent' ? 'mdi-email-send' : 'mdi-email-receive'"
|
||||
size="small"
|
||||
>
|
||||
<template v-slot:opposite>
|
||||
<div class="text-caption">
|
||||
{{ formatDate(email.timestamp) }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-card variant="outlined">
|
||||
<v-card-subtitle class="d-flex align-center">
|
||||
<span class="text-body-2">
|
||||
{{ email.direction === 'sent' ? 'To' : 'From' }}:
|
||||
{{ email.direction === 'sent' ? email.to : email.from }}
|
||||
</span>
|
||||
</v-card-subtitle>
|
||||
|
||||
<v-card-text>
|
||||
<div class="text-body-2 font-weight-medium mb-2">{{ email.subject }}</div>
|
||||
<div class="email-content" v-html="formatEmailContent(email.content)"></div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div v-if="email.attachments && email.attachments.length > 0" class="mt-3">
|
||||
<v-chip
|
||||
v-for="(attachment, i) in email.attachments"
|
||||
:key="i"
|
||||
size="small"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-paperclip"
|
||||
class="mr-2"
|
||||
>
|
||||
{{ attachment.name }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-8 text-grey">
|
||||
<v-icon size="48" class="mb-3">mdi-email-outline</v-icon>
|
||||
<div>No email communication yet</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Email Composer Dialog -->
|
||||
<v-dialog v-model="showComposer" max-width="800" persistent>
|
||||
<v-card>
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-email-edit</v-icon>
|
||||
Compose Email
|
||||
<v-spacer />
|
||||
<v-btn icon="mdi-close" variant="text" @click="closeComposer"></v-btn>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-text>
|
||||
<!-- Recipient Info -->
|
||||
<v-alert type="info" variant="tonal" class="mb-4">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon class="mr-2">mdi-account</v-icon>
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ interest['Full Name'] }}</div>
|
||||
<div class="text-caption">{{ interest['Email Address'] }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<!-- Subject -->
|
||||
<v-text-field
|
||||
v-model="emailDraft.subject"
|
||||
label="Subject"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
class="mb-4"
|
||||
/>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mb-3">
|
||||
<span class="text-body-2 mr-2">Quick Insert:</span>
|
||||
<v-btn
|
||||
v-if="hasEOI"
|
||||
@click="insertEOILink"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-file-document"
|
||||
>
|
||||
EOI Link
|
||||
</v-btn>
|
||||
<v-btn
|
||||
@click="insertFormLink"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-form-select"
|
||||
class="ml-2"
|
||||
>
|
||||
Interest Form
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="hasBerth"
|
||||
@click="insertBerthInfo"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-anchor"
|
||||
class="ml-2"
|
||||
>
|
||||
Berth Info
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Email Content -->
|
||||
<v-textarea
|
||||
v-model="emailDraft.content"
|
||||
label="Email Content"
|
||||
variant="outlined"
|
||||
rows="12"
|
||||
placeholder="Write your email here..."
|
||||
ref="contentTextarea"
|
||||
/>
|
||||
|
||||
<!-- Attachments -->
|
||||
<v-file-input
|
||||
v-model="emailDraft.attachments"
|
||||
label="Attachments"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
multiple
|
||||
chips
|
||||
prepend-icon="mdi-paperclip"
|
||||
class="mt-4"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="closeComposer" variant="text">Cancel</v-btn>
|
||||
<v-btn
|
||||
@click="sendEmail"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
prepend-icon="mdi-send"
|
||||
:loading="isSending"
|
||||
>
|
||||
Send Email
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Interest } from '~/utils/types';
|
||||
|
||||
const props = defineProps<{
|
||||
interest: Interest;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'email-sent': [];
|
||||
'update': [];
|
||||
}>();
|
||||
|
||||
const toast = useToast();
|
||||
const showComposer = ref(false);
|
||||
const isSending = ref(false);
|
||||
const emailThreads = ref<any[]>([]);
|
||||
const contentTextarea = ref<any>(null);
|
||||
|
||||
const emailDraft = ref<{
|
||||
subject: string;
|
||||
content: string;
|
||||
attachments: File[];
|
||||
}>({
|
||||
subject: '',
|
||||
content: '',
|
||||
attachments: []
|
||||
});
|
||||
|
||||
// Check if interest has EOI or berth
|
||||
const hasEOI = computed(() => {
|
||||
const eoiDocs = props.interest['EOI Document'];
|
||||
const hasEOIDocs = Array.isArray(eoiDocs) && eoiDocs.length > 0;
|
||||
return !!(props.interest['Signature Link Client'] || hasEOIDocs);
|
||||
});
|
||||
|
||||
const hasBerth = computed(() => {
|
||||
const berths = props.interest.Berths;
|
||||
const hasBerthsArray = Array.isArray(berths) && berths.length > 0;
|
||||
return !!(hasBerthsArray || props.interest['Berth Number']);
|
||||
});
|
||||
|
||||
// Load email thread on mount
|
||||
onMounted(() => {
|
||||
loadEmailThread();
|
||||
});
|
||||
|
||||
// Watch for interest changes
|
||||
watch(() => props.interest.Id, () => {
|
||||
loadEmailThread();
|
||||
});
|
||||
|
||||
const loadEmailThread = async () => {
|
||||
try {
|
||||
const response = await $fetch<{
|
||||
success: boolean;
|
||||
emails: any[];
|
||||
}>('/api/email/fetch-thread', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': '094ut234'
|
||||
},
|
||||
body: {
|
||||
email: props.interest['Email Address']
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
emailThreads.value = response.emails || [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load email thread:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const insertEOILink = () => {
|
||||
if (!contentTextarea.value) return;
|
||||
|
||||
const link = props.interest['Signature Link Client'] ||
|
||||
props.interest['EOI Client Link'] ||
|
||||
'EOI document has been generated for your review';
|
||||
|
||||
const insertText = `\n\nPlease review and sign your Expression of Interest (EOI) document:\n${link}\n\n`;
|
||||
|
||||
insertAtCursor(insertText);
|
||||
};
|
||||
|
||||
const insertFormLink = () => {
|
||||
const formLink = `https://form.portnimara.com/interest/${props.interest.Id}`;
|
||||
const insertText = `\n\nPlease complete your interest form:\n${formLink}\n\n`;
|
||||
|
||||
insertAtCursor(insertText);
|
||||
};
|
||||
|
||||
const insertBerthInfo = () => {
|
||||
let berthNumber = props.interest['Berth Number'];
|
||||
|
||||
// Check if Berths is an array and has items
|
||||
if (!berthNumber && Array.isArray(props.interest.Berths) && props.interest.Berths.length > 0) {
|
||||
berthNumber = props.interest.Berths[0]['Berth Number'];
|
||||
}
|
||||
|
||||
const insertText = `\n\nBerth Information:\nBerth Number: ${berthNumber || 'TBD'}\n\n`;
|
||||
|
||||
insertAtCursor(insertText);
|
||||
};
|
||||
|
||||
const insertAtCursor = (text: string) => {
|
||||
const textarea = contentTextarea.value.$el.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const current = emailDraft.value.content;
|
||||
|
||||
emailDraft.value.content = current.substring(0, start) + text + current.substring(end);
|
||||
|
||||
// Set cursor position after inserted text
|
||||
nextTick(() => {
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(start + text.length, start + text.length);
|
||||
});
|
||||
};
|
||||
|
||||
const sendEmail = async () => {
|
||||
if (!emailDraft.value.subject || !emailDraft.value.content) {
|
||||
toast.error('Please enter a subject and message');
|
||||
return;
|
||||
}
|
||||
|
||||
isSending.value = true;
|
||||
|
||||
try {
|
||||
// Format email content with proper line breaks
|
||||
const formattedContent = emailDraft.value.content
|
||||
.split('\n')
|
||||
.map(line => line.trim() ? `<p>${line}</p>` : '<br>')
|
||||
.join('');
|
||||
|
||||
// Prepare email data
|
||||
const emailData = {
|
||||
to: props.interest['Email Address'],
|
||||
toName: props.interest['Full Name'],
|
||||
subject: emailDraft.value.subject,
|
||||
html: formattedContent,
|
||||
interestId: props.interest.Id.toString()
|
||||
};
|
||||
|
||||
// Send email
|
||||
const response = await $fetch<{
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
}>('/api/email/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': '094ut234'
|
||||
},
|
||||
body: emailData
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
toast.success('Email sent successfully');
|
||||
|
||||
// Add to thread
|
||||
emailThreads.value.unshift({
|
||||
direction: 'sent',
|
||||
to: props.interest['Email Address'],
|
||||
subject: emailDraft.value.subject,
|
||||
content: formattedContent,
|
||||
timestamp: new Date().toISOString(),
|
||||
attachments: emailDraft.value.attachments.map((f: File) => ({ name: f.name }))
|
||||
});
|
||||
|
||||
// Close composer and reset
|
||||
closeComposer();
|
||||
emit('email-sent');
|
||||
emit('update');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send email:', error);
|
||||
toast.error(error.data?.statusMessage || 'Failed to send email');
|
||||
} finally {
|
||||
isSending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeComposer = () => {
|
||||
showComposer.value = false;
|
||||
// Reset draft
|
||||
emailDraft.value = {
|
||||
subject: '',
|
||||
content: '',
|
||||
attachments: []
|
||||
};
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return '';
|
||||
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatEmailContent = (content: string) => {
|
||||
// Ensure HTML content is properly formatted
|
||||
if (content.includes('<p>') || content.includes('<br>')) {
|
||||
return content;
|
||||
}
|
||||
// Convert plain text to HTML
|
||||
return content.split('\n').map(line => `<p>${line}</p>`).join('');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.email-content {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.email-content :deep(p) {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
|
||||
.email-content :deep(br) {
|
||||
display: block;
|
||||
content: "";
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.email-threads {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,9 +5,31 @@
|
|||
EOI Management
|
||||
</v-card-title>
|
||||
<v-card-text class="pt-0">
|
||||
|
||||
<!-- Generate EOI Button -->
|
||||
<div v-if="!hasEOI" class="mb-4">
|
||||
|
||||
<!-- 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 -->
|
||||
<div v-if="!hasEOI && !hasEOIDocuments" class="mb-4">
|
||||
<v-btn
|
||||
@click="generateEOI"
|
||||
:loading="isGenerating"
|
||||
|
|
@ -18,6 +40,18 @@
|
|||
Generate EOI
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- Upload EOI Button -->
|
||||
<div class="mb-4">
|
||||
<v-btn
|
||||
@click="showUploadDialog = true"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-upload"
|
||||
:disabled="isUploading"
|
||||
>
|
||||
{{ hasEOI ? 'Upload Signed EOI' : 'Upload EOI Document' }}
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- EOI Status Badge -->
|
||||
<div v-if="hasEOI" class="mb-4 d-flex align-center">
|
||||
|
|
@ -100,6 +134,37 @@
|
|||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<v-dialog v-model="showUploadDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>Upload EOI Document</v-card-title>
|
||||
<v-card-text>
|
||||
<v-file-input
|
||||
v-model="selectedFile"
|
||||
label="Select EOI document (PDF)"
|
||||
accept=".pdf"
|
||||
prepend-icon="mdi-file-pdf-box"
|
||||
variant="outlined"
|
||||
density="comfortable"
|
||||
:rules="[v => !!v || 'Please select a file']"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn @click="showUploadDialog = false">Cancel</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="handleUpload"
|
||||
:loading="isUploading"
|
||||
:disabled="!selectedFile"
|
||||
>
|
||||
Upload
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -115,8 +180,11 @@ const emit = defineEmits<{
|
|||
'update': [];
|
||||
}>();
|
||||
|
||||
const { showToast } = useToast();
|
||||
const toast = useToast();
|
||||
const isGenerating = ref(false);
|
||||
const showUploadDialog = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const selectedFile = ref<File | null>(null);
|
||||
|
||||
const hasEOI = computed(() => {
|
||||
return !!(props.interest['Signature Link Client'] ||
|
||||
|
|
@ -124,7 +192,15 @@ const hasEOI = computed(() => {
|
|||
props.interest['Signature Link Developer']);
|
||||
});
|
||||
|
||||
const generateEOI = async () => {
|
||||
const eoiDocuments = computed(() => {
|
||||
return props.interest['EOI Document'] || [];
|
||||
});
|
||||
|
||||
const hasEOIDocuments = computed(() => {
|
||||
return eoiDocuments.value.length > 0;
|
||||
});
|
||||
|
||||
const generateEOI = async (retryCount = 0) => {
|
||||
isGenerating.value = true;
|
||||
|
||||
try {
|
||||
|
|
@ -144,16 +220,27 @@ const generateEOI = async () => {
|
|||
});
|
||||
|
||||
if (response.success) {
|
||||
showToast(response.documentId === 'existing'
|
||||
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
|
||||
} else {
|
||||
throw new Error('EOI generation failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate EOI:', error);
|
||||
showToast(error.data?.statusMessage || 'Failed to generate EOI');
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -164,7 +251,7 @@ const copyLink = async (link: string | undefined) => {
|
|||
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
showToast('Signature link copied to clipboard');
|
||||
toast.success('Signature link copied to clipboard');
|
||||
|
||||
// Update EOI Time Sent if not already set
|
||||
if (!props.interest['EOI Time Sent']) {
|
||||
|
|
@ -187,7 +274,7 @@ const copyLink = async (link: string | undefined) => {
|
|||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to copy link');
|
||||
toast.error('Failed to copy link');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -225,4 +312,45 @@ const getStatusColor = (status: string) => {
|
|||
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',
|
||||
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);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -184,14 +184,12 @@
|
|||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<div style="height: 600px; overflow: hidden;">
|
||||
<file-browser-component
|
||||
v-if="showFileBrowser"
|
||||
:selection-mode="true"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
<v-card-text class="pa-0" style="height: 70vh; overflow-y: auto;">
|
||||
<file-browser-component
|
||||
v-if="showFileBrowser"
|
||||
:selection-mode="true"
|
||||
@file-selected="onFileSelected"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider />
|
||||
|
|
|
|||
|
|
@ -131,7 +131,6 @@
|
|||
v-if="interest['EOI Time Sent']"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-email-fast"
|
||||
>
|
||||
EOI Sent: {{ formatDate(interest["EOI Time Sent"]) }}
|
||||
</v-chip>
|
||||
|
|
@ -624,10 +623,11 @@
|
|||
</v-card>
|
||||
|
||||
<!-- Email Communication Section -->
|
||||
<EmailCommunication
|
||||
<ClientEmailSection
|
||||
v-if="interest"
|
||||
:interest="interest"
|
||||
@interestUpdated="onInterestUpdated"
|
||||
@email-sent="onInterestUpdated"
|
||||
@update="onInterestUpdated"
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
|
|
@ -658,7 +658,7 @@ function debounce<T extends (...args: any[]) => any>(
|
|||
return debounced;
|
||||
}
|
||||
import PhoneInput from "./PhoneInput.vue";
|
||||
import EmailCommunication from "./EmailCommunication.vue";
|
||||
import ClientEmailSection from "./ClientEmailSection.vue";
|
||||
import EOISection from "./EOISection.vue";
|
||||
import {
|
||||
InterestSalesProcessLevelFlow,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,235 @@
|
|||
# EOI Automation System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The EOI (Expression of Interest) automation system provides comprehensive management of EOI documents, including:
|
||||
- Manual EOI upload capability
|
||||
- Automated signature status tracking via Documeso API
|
||||
- Automated reminder emails for unsigned documents
|
||||
- Automated processing of EOI attachments from sales@portnimara.com
|
||||
|
||||
## Components
|
||||
|
||||
### 1. EOI Section Component (`components/EOISection.vue`)
|
||||
|
||||
**Features:**
|
||||
- Display EOI documents associated with an interest
|
||||
- Generate new EOI documents via Documeso
|
||||
- Upload signed EOI documents manually
|
||||
- Display signature links for all parties (Client, CC, Developer)
|
||||
- Track EOI status and signing time
|
||||
|
||||
**Key Functions:**
|
||||
- `generateEOI()` - Creates new EOI document via Documeso API
|
||||
- `uploadEOI()` - Uploads PDF documents to MinIO
|
||||
- `copyLink()` - Copies signature link and tracks when sent
|
||||
|
||||
### 2. Documeso API Integration (`server/utils/documeso.ts`)
|
||||
|
||||
**Configuration:**
|
||||
- API URL: https://signatures.portnimara.dev/api/v1
|
||||
- API Key: Bearer api_malptg62zqyb0wrp
|
||||
|
||||
**Key Functions:**
|
||||
- `getDocumesoDocument()` - Fetch document by ID
|
||||
- `getDocumesoDocumentByExternalId()` - Find document by external ID (e.g., 'loi-94')
|
||||
- `checkDocumentSignatureStatus()` - Check signature status of all recipients
|
||||
- `getRecipientsToRemind()` - Get recipients who need reminders (after client has signed)
|
||||
|
||||
### 3. Reminder System
|
||||
|
||||
#### API Endpoints:
|
||||
- `/api/eoi/check-signature-status` - Check signature status of an EOI
|
||||
- `/api/eoi/send-reminders` - Send reminder emails
|
||||
|
||||
#### Scheduled Tasks (`server/tasks/eoi-reminders.ts`):
|
||||
- Runs at 9am and 4pm daily (Europe/Paris timezone)
|
||||
- Checks all interests with EOI documents
|
||||
- Sends reminders based on rules:
|
||||
- 4pm only: Reminder to sales if client hasn't signed
|
||||
- 9am & 4pm: Reminders to CC/Developer if client has signed but they haven't
|
||||
- Maximum one reminder per 12 hours per interest
|
||||
|
||||
#### Email Templates:
|
||||
- **Sales Reminder**: Notifies sales team when client hasn't signed
|
||||
- **Recipient Reminder**: Personalized reminder for CC/Developer to sign
|
||||
|
||||
### 4. Sales Email Processing (`server/api/email/process-sales-eois.ts`)
|
||||
|
||||
**Features:**
|
||||
- Monitors sales@portnimara.com inbox every 30 minutes
|
||||
- Processes unread emails with PDF attachments
|
||||
- Automatically extracts client name from filename or subject
|
||||
- Uploads EOI documents to MinIO
|
||||
- Updates interest record with EOI document and status
|
||||
|
||||
**Client Name Extraction Patterns:**
|
||||
- Filename: "John_Doe_EOI_signed.pdf", "EOI_John_Doe.pdf", "John Doe - EOI.pdf"
|
||||
- Subject: "EOI for John Doe signed", "Signed EOI - John Doe"
|
||||
|
||||
## Database Schema Updates
|
||||
|
||||
### Interest Table Fields:
|
||||
|
||||
```typescript
|
||||
// EOI Document storage
|
||||
'EOI Document': EOIDocument[] // Array of uploaded EOI documents
|
||||
|
||||
// Signature tracking
|
||||
'Signature Link Client': string
|
||||
'Signature Link CC': string
|
||||
'Signature Link Developer': string
|
||||
'documeso_document_id': string // Documeso document ID
|
||||
|
||||
// Reminder tracking
|
||||
'reminder_enabled': boolean // Enable/disable reminders
|
||||
'last_reminder_sent': string // ISO timestamp of last reminder
|
||||
|
||||
// Status tracking
|
||||
'EOI Status': 'Waiting for Signatures' | 'Signed'
|
||||
'EOI Time Sent': string // When EOI was first sent
|
||||
```
|
||||
|
||||
### EOIDocument Type:
|
||||
|
||||
```typescript
|
||||
interface EOIDocument {
|
||||
title: string
|
||||
filename: string
|
||||
url: string
|
||||
size: number
|
||||
mimetype: string
|
||||
icon: string
|
||||
uploadedAt?: string
|
||||
source?: 'email' | 'manual'
|
||||
from?: string // Email sender if from email
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Generate EOI Document
|
||||
```http
|
||||
POST /api/email/generate-eoi-document
|
||||
Headers: x-tag: 094ut234
|
||||
Body: { interestId: string }
|
||||
```
|
||||
|
||||
### Upload EOI Document
|
||||
```http
|
||||
POST /api/eoi/upload-document?interestId=123
|
||||
Headers: x-tag: 094ut234
|
||||
Body: FormData with 'file' field
|
||||
```
|
||||
|
||||
### Check Signature Status
|
||||
```http
|
||||
GET /api/eoi/check-signature-status?interestId=123
|
||||
Headers: x-tag: 094ut234
|
||||
```
|
||||
|
||||
### Send Reminders
|
||||
```http
|
||||
POST /api/eoi/send-reminders
|
||||
Headers: x-tag: 094ut234
|
||||
Body: { interestId: string, documentId: string }
|
||||
```
|
||||
|
||||
### Process Sales Emails
|
||||
```http
|
||||
POST /api/email/process-sales-eois
|
||||
Headers: x-tag: 094ut234
|
||||
```
|
||||
|
||||
## Email Configuration
|
||||
|
||||
### Reminder Emails (noreply@portnimara.com)
|
||||
- Host: mail.portnimara.com
|
||||
- Port: 465
|
||||
- Secure: true
|
||||
- User: noreply@portnimara.com
|
||||
- Pass: sJw6GW5G5bCI1EtBIq3J2hVm8xCOMw1kQs1puS6g0yABqkrwj
|
||||
|
||||
### Sales Email Monitoring (sales@portnimara.com)
|
||||
- Host: mail.portnimara.com
|
||||
- Port: 993 (IMAP)
|
||||
- TLS: true
|
||||
- User: sales@portnimara.com
|
||||
- Pass: MDze7cSClQok8qWOf23X8Mb6lArdk0i42YnwJ1FskdtO2NCc9
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing Commands
|
||||
|
||||
1. **Generate EOI for Interest #94:**
|
||||
```javascript
|
||||
await $fetch('/api/email/generate-eoi-document', {
|
||||
method: 'POST',
|
||||
headers: { 'x-tag': '094ut234' },
|
||||
body: { interestId: '94' }
|
||||
})
|
||||
```
|
||||
|
||||
2. **Check Signature Status:**
|
||||
```javascript
|
||||
await $fetch('/api/eoi/check-signature-status?interestId=94', {
|
||||
headers: { 'x-tag': '094ut234' }
|
||||
})
|
||||
```
|
||||
|
||||
3. **Trigger Reminder Processing:**
|
||||
```javascript
|
||||
// In server console
|
||||
import { triggerReminders } from '~/server/tasks/eoi-reminders'
|
||||
await triggerReminders()
|
||||
```
|
||||
|
||||
4. **Trigger Email Processing:**
|
||||
```javascript
|
||||
// In server console
|
||||
import { triggerEmailProcessing } from '~/server/tasks/process-sales-emails'
|
||||
await triggerEmailProcessing()
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues:
|
||||
|
||||
1. **EOI Generation Fails**
|
||||
- Check Documeso API credentials
|
||||
- Verify interest has required fields (Full Name, Email, etc.)
|
||||
- Check API rate limits
|
||||
|
||||
2. **Reminders Not Sending**
|
||||
- Verify SMTP credentials
|
||||
- Check reminder_enabled field is not false
|
||||
- Ensure documeso_document_id is set
|
||||
- Check last_reminder_sent timestamp
|
||||
|
||||
3. **Email Processing Not Working**
|
||||
- Verify IMAP credentials
|
||||
- Check sales@portnimara.com inbox access
|
||||
- Ensure emails have PDF attachments
|
||||
- Verify client name extraction patterns
|
||||
|
||||
4. **Signature Status Not Updating**
|
||||
- Check Documeso API connectivity
|
||||
- Verify document exists in Documeso
|
||||
- Check external ID format (loi-{interestId})
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. All API endpoints require x-tag authentication
|
||||
2. Email credentials are stored securely
|
||||
3. Uploaded files are stored in MinIO with access control
|
||||
4. Signature links are unique and time-limited
|
||||
5. Reminder emails are sent to verified addresses only
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. Add webhook support for real-time signature updates
|
||||
2. Implement customizable reminder schedules
|
||||
3. Add email template customization
|
||||
4. Support for multiple document types beyond EOI
|
||||
5. Add audit logging for all EOI operations
|
||||
6. Implement retry queue for failed email processing
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@vite-pwa/nuxt": "^0.10.6",
|
||||
"formidable": "^3.5.4",
|
||||
"imap": "^0.8.19",
|
||||
|
|
@ -14,6 +15,7 @@
|
|||
"mailparser": "^3.7.3",
|
||||
"mime-types": "^3.0.1",
|
||||
"minio": "^8.0.5",
|
||||
"node-cron": "^4.1.0",
|
||||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"nuxt-directus": "^5.7.0",
|
||||
|
|
@ -23,8 +25,10 @@
|
|||
"vuetify-nuxt-module": "^0.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/nodemailer": "^6.4.17"
|
||||
}
|
||||
},
|
||||
|
|
@ -3693,6 +3697,16 @@
|
|||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/formidable": {
|
||||
"version": "3.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.5.tgz",
|
||||
"integrity": "sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/http-proxy": {
|
||||
"version": "1.17.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz",
|
||||
|
|
@ -3738,6 +3752,13 @@
|
|||
"iconv-lite": "^0.6.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mime-types": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
|
||||
"integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
|
||||
|
|
@ -3747,6 +3768,12 @@
|
|||
"undici-types": "~6.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node-cron": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
|
||||
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||
|
|
@ -9446,6 +9473,15 @@
|
|||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-cron": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.1.0.tgz",
|
||||
"integrity": "sha512-OS+3ORu+h03/haS6Di8Qr7CrVs4YaKZZOynZwQpyPZDnR3tqRbwJmuP2gVR16JfhLgyNlloAV1VTrrWlRogCFA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@vite-pwa/nuxt": "^0.10.6",
|
||||
"formidable": "^3.5.4",
|
||||
"imap": "^0.8.19",
|
||||
|
|
@ -16,6 +17,7 @@
|
|||
"mailparser": "^3.7.3",
|
||||
"mime-types": "^3.0.1",
|
||||
"minio": "^8.0.5",
|
||||
"node-cron": "^4.1.0",
|
||||
"nodemailer": "^7.0.3",
|
||||
"nuxt": "^3.15.4",
|
||||
"nuxt-directus": "^5.7.0",
|
||||
|
|
@ -25,8 +27,10 @@
|
|||
"vuetify-nuxt-module": "^0.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/formidable": "^3.4.5",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/nodemailer": "^6.4.17"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- Header -->
|
||||
<v-row class="mb-6">
|
||||
<v-row class="mb-6" v-if="!props.selectionMode">
|
||||
<v-col>
|
||||
<h1 class="text-h4 font-weight-bold">
|
||||
File Browser
|
||||
|
|
@ -12,14 +12,26 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Selection Mode Header -->
|
||||
<v-row v-if="props.selectionMode" class="mb-4">
|
||||
<v-col>
|
||||
<h2 class="text-h5 font-weight-bold">
|
||||
Select Files to Attach
|
||||
</h2>
|
||||
<p class="text-subtitle-2 text-grey mt-1">
|
||||
Click on files to attach them to your email
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<v-row class="mb-4" v-if="currentPath">
|
||||
<v-row class="mb-4" v-if="currentPath && !props.selectionMode">
|
||||
<v-col>
|
||||
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
|
||||
<template v-slot:item="{ item }">
|
||||
<v-breadcrumbs-item
|
||||
:to="item.to"
|
||||
@click="navigateToFolder(item.path)"
|
||||
@click="navigateToFolder((item as any).path)"
|
||||
class="cursor-pointer"
|
||||
>
|
||||
{{ item.title }}
|
||||
</v-breadcrumbs-item>
|
||||
|
|
@ -41,7 +53,7 @@
|
|||
@update:model-value="filterFiles"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="6" class="d-flex justify-end ga-2">
|
||||
<v-col cols="12" md="6" class="d-flex justify-end ga-2" v-if="!props.selectionMode">
|
||||
<v-btn
|
||||
color="secondary"
|
||||
size="large"
|
||||
|
|
@ -63,7 +75,7 @@
|
|||
</v-row>
|
||||
|
||||
<!-- Bulk Actions Bar (shown when items selected) -->
|
||||
<v-row v-if="selectedItems.length > 0" class="mb-4">
|
||||
<v-row v-if="selectedItems.length > 0 && !props.selectionMode" class="mb-4">
|
||||
<v-col>
|
||||
<v-alert
|
||||
type="info"
|
||||
|
|
@ -312,7 +324,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import FileUploader from '~/components/FileUploader.vue';
|
||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||
|
||||
|
|
@ -325,8 +337,20 @@ interface FileItem {
|
|||
icon: string;
|
||||
displayName: string;
|
||||
isFolder: boolean;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
selectionMode?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'file-selected', file: FileItem): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// Data
|
||||
|
|
@ -383,22 +407,42 @@ const breadcrumbItems = computed(() => {
|
|||
return items;
|
||||
});
|
||||
|
||||
// Load files
|
||||
const loadFiles = async () => {
|
||||
// Load files with retry logic
|
||||
const loadFiles = async (retryCount = 0) => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await $fetch('/api/files/list', {
|
||||
params: {
|
||||
prefix: currentPath.value,
|
||||
recursive: false,
|
||||
}
|
||||
},
|
||||
timeout: 15000 // 15 second timeout
|
||||
});
|
||||
files.value = response.files;
|
||||
filteredFiles.value = response.files;
|
||||
} catch (error) {
|
||||
toast.error('Failed to load files');
|
||||
files.value = response.files || [];
|
||||
filteredFiles.value = response.files || [];
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to load files (attempt ${retryCount + 1}/3):`, error);
|
||||
|
||||
// Retry on certain errors
|
||||
if (retryCount < 2 && (
|
||||
error.message?.includes('Failed to fetch') ||
|
||||
error.message?.includes('Network') ||
|
||||
error.statusCode === 500 ||
|
||||
error.statusCode === 503
|
||||
)) {
|
||||
console.log('Retrying file load...');
|
||||
setTimeout(() => {
|
||||
loadFiles(retryCount + 1);
|
||||
}, (retryCount + 1) * 1000); // Exponential backoff
|
||||
} else {
|
||||
toast.error('Failed to load files. Please refresh the page.');
|
||||
files.value = [];
|
||||
filteredFiles.value = [];
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
if (retryCount === 0 || retryCount === 2) {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -417,6 +461,15 @@ const filterFiles = () => {
|
|||
|
||||
// Handle file/folder click
|
||||
const handleFileClick = (item: FileItem) => {
|
||||
if (props.selectionMode && !item.isFolder) {
|
||||
// In selection mode, emit the file for attachment
|
||||
emit('file-selected', {
|
||||
...item,
|
||||
path: item.name
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.isFolder) {
|
||||
navigateToFolder(item.name);
|
||||
} else if (canPreview(item)) {
|
||||
|
|
|
|||
|
|
@ -388,40 +388,81 @@ async function fetchImapEmails(
|
|||
// Group emails into threads based on subject and references
|
||||
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
||||
const threads = new Map<string, EmailMessage[]>();
|
||||
const emailById = new Map<string, EmailMessage>();
|
||||
|
||||
// First pass: index all emails by ID
|
||||
emails.forEach(email => {
|
||||
// Normalize subject by removing Re:, Fwd:, etc.
|
||||
emailById.set(email.id, email);
|
||||
});
|
||||
|
||||
// Second pass: group emails into threads
|
||||
emails.forEach(email => {
|
||||
// Normalize subject by removing Re:, Fwd:, etc. and extra whitespace
|
||||
const normalizedSubject = email.subject
|
||||
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
||||
.trim();
|
||||
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// Find existing thread or create new one
|
||||
// Check if this email belongs to an existing thread
|
||||
let threadFound = false;
|
||||
for (const [threadId, threadEmails] of threads.entries()) {
|
||||
const threadSubject = threadEmails[0].subject
|
||||
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
||||
.trim();
|
||||
|
||||
if (threadSubject === normalizedSubject) {
|
||||
threadEmails.push(email);
|
||||
threadFound = true;
|
||||
break;
|
||||
|
||||
// First, check if it has a threadId (in-reply-to header)
|
||||
if (email.threadId) {
|
||||
// Look for the parent email
|
||||
const parentEmail = emailById.get(email.threadId);
|
||||
if (parentEmail) {
|
||||
// Find which thread the parent belongs to
|
||||
for (const [threadId, threadEmails] of threads.entries()) {
|
||||
if (threadEmails.some(e => e.id === parentEmail.id)) {
|
||||
threadEmails.push(email);
|
||||
threadFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not found by threadId, try to match by subject
|
||||
if (!threadFound) {
|
||||
for (const [threadId, threadEmails] of threads.entries()) {
|
||||
const threadSubject = threadEmails[0].subject
|
||||
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// Check if subjects match (case-insensitive)
|
||||
if (threadSubject === normalizedSubject) {
|
||||
threadEmails.push(email);
|
||||
threadFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still not found, create a new thread
|
||||
if (!threadFound) {
|
||||
threads.set(email.id, [email]);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array format and sort threads by latest timestamp (newest first)
|
||||
// Convert to array format and sort emails within each thread
|
||||
return Array.from(threads.entries())
|
||||
.map(([threadId, emails]) => ({
|
||||
id: threadId,
|
||||
subject: emails[0].subject,
|
||||
emailCount: emails.length,
|
||||
latestTimestamp: emails[0].timestamp, // First email is newest since we sorted desc
|
||||
emails: emails
|
||||
}))
|
||||
.map(([threadId, threadEmails]) => {
|
||||
// Sort emails within thread by timestamp (oldest first for chronological order)
|
||||
threadEmails.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
id: threadId,
|
||||
subject: threadEmails[0].subject,
|
||||
emailCount: threadEmails.length,
|
||||
latestTimestamp: threadEmails[threadEmails.length - 1].timestamp, // Latest email
|
||||
emails: threadEmails
|
||||
};
|
||||
})
|
||||
// Sort threads by latest activity (newest first)
|
||||
.sort((a, b) => new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
import { parseEmail, getIMAPConnection } from '~/server/utils/email-utils';
|
||||
import { uploadFile } from '~/server/utils/minio';
|
||||
import { getInterestByFieldAsync, updateInterest } from '~/server/utils/nocodb';
|
||||
import type { ParsedMail } from 'mailparser';
|
||||
|
||||
interface ProcessedEOI {
|
||||
clientName: string;
|
||||
interestId?: string;
|
||||
fileName: string;
|
||||
processed: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[Process Sales EOIs] Starting email processing...');
|
||||
|
||||
// Sales email credentials
|
||||
const credentials = {
|
||||
user: 'sales@portnimara.com',
|
||||
password: 'MDze7cSClQok8qWOf23X8Mb6lArdk0i42YnwJ1FskdtO2NCc9',
|
||||
host: 'mail.portnimara.com',
|
||||
port: 993,
|
||||
tls: true
|
||||
};
|
||||
|
||||
const connection = await getIMAPConnection(credentials);
|
||||
const results: ProcessedEOI[] = [];
|
||||
|
||||
try {
|
||||
// Open inbox
|
||||
await new Promise((resolve, reject) => {
|
||||
connection.openBox('INBOX', false, (err: any, box: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(box);
|
||||
});
|
||||
});
|
||||
|
||||
// Search for unread emails with attachments
|
||||
const searchCriteria = ['UNSEEN'];
|
||||
|
||||
const messages = await new Promise<number[]>((resolve, reject) => {
|
||||
connection.search(searchCriteria, (err: any, results: any) => {
|
||||
if (err) reject(err);
|
||||
else resolve(results || []);
|
||||
});
|
||||
});
|
||||
|
||||
console.log(`[Process Sales EOIs] Found ${messages.length} unread messages`);
|
||||
|
||||
for (const msgNum of messages) {
|
||||
try {
|
||||
const parsedEmail = await fetchAndParseEmail(connection, msgNum);
|
||||
|
||||
if (parsedEmail.attachments && parsedEmail.attachments.length > 0) {
|
||||
// Process PDF attachments
|
||||
for (const attachment of parsedEmail.attachments) {
|
||||
if (attachment.contentType === 'application/pdf') {
|
||||
const result = await processEOIAttachment(
|
||||
attachment,
|
||||
parsedEmail.subject || '',
|
||||
parsedEmail.from?.text || ''
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as read
|
||||
connection.addFlags(msgNum, '\\Seen', (err: any) => {
|
||||
if (err) console.error('Failed to mark message as read:', err);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Process Sales EOIs] Error processing message ${msgNum}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
connection.end();
|
||||
} catch (error) {
|
||||
connection.end();
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
processed: results.length,
|
||||
results
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('[Process Sales EOIs] Failed to process emails:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to process sales emails',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchAndParseEmail(connection: any, msgNum: number): Promise<ParsedMail> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fetch = connection.fetch(msgNum, {
|
||||
bodies: '',
|
||||
struct: true
|
||||
});
|
||||
|
||||
fetch.on('message', (msg: any) => {
|
||||
let buffer = '';
|
||||
|
||||
msg.on('body', (stream: any) => {
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
stream.once('end', async () => {
|
||||
try {
|
||||
const parsed = await parseEmail(buffer);
|
||||
resolve(parsed);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function processEOIAttachment(
|
||||
attachment: any,
|
||||
subject: string,
|
||||
from: string
|
||||
): Promise<ProcessedEOI> {
|
||||
const fileName = attachment.filename || 'unknown.pdf';
|
||||
|
||||
try {
|
||||
console.log(`[Process Sales EOIs] Processing attachment: ${fileName}`);
|
||||
|
||||
// Try to extract client name from filename or subject
|
||||
const clientName = extractClientName(fileName, subject);
|
||||
|
||||
if (!clientName) {
|
||||
return {
|
||||
clientName: 'Unknown',
|
||||
fileName,
|
||||
processed: false,
|
||||
error: 'Could not identify client from filename or subject'
|
||||
};
|
||||
}
|
||||
|
||||
// Find interest by client name
|
||||
const interest = await getInterestByFieldAsync('Full Name', clientName);
|
||||
|
||||
if (!interest) {
|
||||
return {
|
||||
clientName,
|
||||
fileName,
|
||||
processed: false,
|
||||
error: `No interest found for client: ${clientName}`
|
||||
};
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const timestamp = Date.now();
|
||||
const uploadFileName = `EOIs/${interest.Id}-${timestamp}-${fileName}`;
|
||||
|
||||
// Upload to MinIO
|
||||
await uploadFile(uploadFileName, attachment.content, 'application/pdf');
|
||||
|
||||
// Update interest with EOI document
|
||||
const documentData = {
|
||||
title: fileName,
|
||||
filename: uploadFileName,
|
||||
url: `/api/files/proxy-download?fileName=${encodeURIComponent(uploadFileName)}`,
|
||||
size: attachment.size,
|
||||
mimetype: 'application/pdf',
|
||||
icon: 'mdi-file-pdf-box',
|
||||
uploadedAt: new Date().toISOString(),
|
||||
source: 'email',
|
||||
from: from
|
||||
};
|
||||
|
||||
// Get existing documents and add new one
|
||||
const existingDocs = interest['EOI Document'] || [];
|
||||
const updatedDocs = [...existingDocs, documentData];
|
||||
|
||||
// Update interest
|
||||
await updateInterest(interest.Id.toString(), {
|
||||
'EOI Document': updatedDocs,
|
||||
'EOI Status': 'Signed',
|
||||
'Sales Process Level': 'Signed LOI and NDA'
|
||||
});
|
||||
|
||||
console.log(`[Process Sales EOIs] Successfully processed EOI for ${clientName}`);
|
||||
|
||||
return {
|
||||
clientName,
|
||||
interestId: interest.Id.toString(),
|
||||
fileName,
|
||||
processed: true
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error(`[Process Sales EOIs] Error processing attachment:`, error);
|
||||
return {
|
||||
clientName: 'Unknown',
|
||||
fileName,
|
||||
processed: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function extractClientName(fileName: string, subject: string): string | null {
|
||||
// Try to extract from filename patterns like:
|
||||
// "John_Doe_EOI_signed.pdf"
|
||||
// "EOI_John_Doe.pdf"
|
||||
// "John Doe - EOI.pdf"
|
||||
|
||||
// First try filename
|
||||
const filePatterns = [
|
||||
/^(.+?)[-_]EOI/i,
|
||||
/EOI[-_](.+?)\.pdf/i,
|
||||
/^(.+?)_signed/i,
|
||||
/^(.+?)\s*-\s*EOI/i
|
||||
];
|
||||
|
||||
for (const pattern of filePatterns) {
|
||||
const match = fileName.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1].replace(/[_-]/g, ' ').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Then try subject
|
||||
const subjectPatterns = [
|
||||
/EOI\s+(?:for\s+)?(.+?)(?:\s+signed)?$/i,
|
||||
/Signed\s+EOI\s*[-:]?\s*(.+)$/i,
|
||||
/(.+?)\s*EOI\s*(?:signed|completed)/i
|
||||
];
|
||||
|
||||
for (const pattern of subjectPatterns) {
|
||||
const match = subject.match(pattern);
|
||||
if (match && match[1]) {
|
||||
return match[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -66,17 +66,28 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
};
|
||||
|
||||
const testImapConnection = () => {
|
||||
const testImapConnection = (retryCount = 0): Promise<boolean> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[test-connection] Testing IMAP connection...');
|
||||
console.log(`[test-connection] Testing IMAP connection... (Attempt ${retryCount + 1}/3)`);
|
||||
const imap = new Imap(imapConfig);
|
||||
|
||||
// Add a timeout to prevent hanging
|
||||
const timeout = setTimeout(() => {
|
||||
console.error('[test-connection] IMAP connection timeout');
|
||||
imap.end();
|
||||
reject(new Error('IMAP connection timeout after 10 seconds'));
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
// Retry on timeout if we haven't exceeded max retries
|
||||
if (retryCount < 2) {
|
||||
console.log('[test-connection] Retrying IMAP connection after timeout...');
|
||||
setTimeout(() => {
|
||||
testImapConnection(retryCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}, (retryCount + 1) * 1000); // Exponential backoff
|
||||
} else {
|
||||
reject(new Error('IMAP connection timeout after 15 seconds and 3 attempts'));
|
||||
}
|
||||
}, 15000); // 15 second timeout per attempt
|
||||
|
||||
imap.once('ready', () => {
|
||||
console.log('[test-connection] IMAP connection successful');
|
||||
|
|
@ -88,7 +99,25 @@ export default defineEventHandler(async (event) => {
|
|||
imap.once('error', (err: Error) => {
|
||||
console.error('[test-connection] IMAP connection error:', err);
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
|
||||
// Retry on certain errors if we haven't exceeded max retries
|
||||
const shouldRetry = retryCount < 2 && (
|
||||
err.message.includes('ECONNRESET') ||
|
||||
err.message.includes('ETIMEDOUT') ||
|
||||
err.message.includes('ENOTFOUND') ||
|
||||
err.message.includes('socket hang up')
|
||||
);
|
||||
|
||||
if (shouldRetry) {
|
||||
console.log(`[test-connection] Retrying IMAP connection after error: ${err.message}`);
|
||||
setTimeout(() => {
|
||||
testImapConnection(retryCount + 1)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}, (retryCount + 1) * 1000); // Exponential backoff
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
|
|
@ -98,7 +127,7 @@ export default defineEventHandler(async (event) => {
|
|||
try {
|
||||
await testImapConnection();
|
||||
} catch (imapError: any) {
|
||||
console.error('[test-connection] IMAP connection failed:', imapError);
|
||||
console.error('[test-connection] IMAP connection failed after all retries:', imapError);
|
||||
throw new Error(`IMAP connection failed: ${imapError.message || imapError}`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
import { getDocumesoDocumentByExternalId, checkDocumentSignatureStatus } from '~/server/utils/documeso';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
const query = getQuery(event);
|
||||
const interestId = query.interestId as string;
|
||||
const documentId = query.documentId as string;
|
||||
|
||||
if (!interestId && !documentId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Either interest ID or document ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
// If we have a document ID, check directly
|
||||
if (documentId) {
|
||||
const status = await checkDocumentSignatureStatus(parseInt(documentId));
|
||||
return {
|
||||
success: true,
|
||||
...status
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, try to find by external ID (using interestId)
|
||||
const externalId = `loi-${interestId}`;
|
||||
const document = await getDocumesoDocumentByExternalId(externalId);
|
||||
|
||||
if (!document) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Document not found for this interest',
|
||||
});
|
||||
}
|
||||
|
||||
const status = await checkDocumentSignatureStatus(document.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
documentId: document.id,
|
||||
...status
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Failed to check signature status:', error);
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || 'Failed to check signature status',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import { getDocumesoDocument, checkDocumentSignatureStatus, formatRecipientName } from '~/server/utils/documeso';
|
||||
import { getInterestById } from '~/server/utils/nocodb';
|
||||
import { sendEmail } from '~/server/utils/email';
|
||||
|
||||
interface ReminderEmail {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await readBody(event);
|
||||
const { interestId, documentId } = body;
|
||||
|
||||
if (!interestId || !documentId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Interest ID and Document ID are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Get interest details
|
||||
const interest = await getInterestById(interestId);
|
||||
if (!interest) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Interest not found',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if reminders are enabled for this interest
|
||||
// For now, we'll assume they're always enabled unless explicitly disabled
|
||||
const remindersEnabled = (interest as any)['reminder_enabled'] !== false;
|
||||
|
||||
if (!remindersEnabled) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Reminders are disabled for this interest'
|
||||
};
|
||||
}
|
||||
|
||||
// Get document and check signature status
|
||||
const document = await getDocumesoDocument(parseInt(documentId));
|
||||
const status = await checkDocumentSignatureStatus(parseInt(documentId));
|
||||
|
||||
const emailsToSend: ReminderEmail[] = [];
|
||||
const currentHour = new Date().getHours();
|
||||
|
||||
// Determine if we should send reminders based on time
|
||||
const shouldSendMorningReminder = currentHour === 9;
|
||||
const shouldSendAfternoonReminder = currentHour === 16;
|
||||
|
||||
if (!shouldSendMorningReminder && !shouldSendAfternoonReminder) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Reminders are only sent at 9am and 4pm'
|
||||
};
|
||||
}
|
||||
|
||||
// If client hasn't signed, send reminder to sales (4pm only)
|
||||
if (!status.clientSigned && shouldSendAfternoonReminder) {
|
||||
const salesEmail = generateSalesReminderEmail(interest, document);
|
||||
emailsToSend.push(salesEmail);
|
||||
}
|
||||
|
||||
// If client has signed but others haven't, send reminders to them
|
||||
if (status.clientSigned && !status.allSigned) {
|
||||
for (const recipient of status.unsignedRecipients) {
|
||||
if (recipient.signingOrder > 1) { // Skip client
|
||||
const reminderEmail = generateRecipientReminderEmail(
|
||||
recipient,
|
||||
interest['Full Name'] || 'Client',
|
||||
recipient.signingUrl
|
||||
);
|
||||
emailsToSend.push(reminderEmail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send all emails
|
||||
const results = [];
|
||||
for (const email of emailsToSend) {
|
||||
try {
|
||||
await sendReminderEmail(email);
|
||||
results.push({
|
||||
to: email.to,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Failed to send reminder to ${email.to}:`, error);
|
||||
results.push({
|
||||
to: email.to,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update last reminder sent timestamp
|
||||
await $fetch('/api/update-interest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': xTagHeader,
|
||||
},
|
||||
body: {
|
||||
id: interestId,
|
||||
data: {
|
||||
'last_reminder_sent': new Date().toISOString()
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
remindersSent: results.length,
|
||||
results
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Failed to send reminders:', error);
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
statusMessage: error.statusMessage || 'Failed to send reminders',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function generateRecipientReminderEmail(
|
||||
recipient: any,
|
||||
clientName: string,
|
||||
signUrl: string
|
||||
): ReminderEmail {
|
||||
const recipientFirst = formatRecipientName(recipient);
|
||||
const clientFormatted = clientName;
|
||||
|
||||
const html = `<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge" /><!--<![endif]-->
|
||||
<title>Port Nimara EOI Signature Request</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace:0pt; mso-table-rspace:0pt; }
|
||||
img { border:0; display:block; }
|
||||
p { margin:0; padding:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<!--[if gte mso 9]>
|
||||
<v:background xmlns:v="urn:schemas-microsoft-com:vml" fill="true">
|
||||
<v:fill type="frame" src="https://s3.portnimara.com/images/Overhead_1_blur.png" color="#f2f2f2" />
|
||||
</v:background>
|
||||
<![endif]-->
|
||||
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"
|
||||
style="background-image:url('https://s3.portnimara.com/images/Overhead_1_blur.png');
|
||||
background-size:cover;
|
||||
background-position:center;
|
||||
background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px;">
|
||||
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0"
|
||||
style="background-color:#ffffff;
|
||||
border-radius:8px;
|
||||
overflow:hidden;
|
||||
box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family:Arial, sans-serif; color:#333333;">
|
||||
<!-- logo -->
|
||||
<center>
|
||||
<img
|
||||
src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png"
|
||||
alt="Port Nimara Logo"
|
||||
width="100"
|
||||
style="margin-bottom:20px;"
|
||||
/>
|
||||
</center>
|
||||
|
||||
<!-- greeting & body -->
|
||||
<p style="margin-bottom:10px; font-size:16px;">
|
||||
Dear <strong>${recipientFirst}</strong>,
|
||||
</p>
|
||||
<p style="margin-bottom:20px; font-size:16px;">
|
||||
There is an EOI from <strong>${clientFormatted}</strong> waiting to be signed.
|
||||
Please click the button below to review and sign the document.
|
||||
If you need any assistance, please reach out to the sales team.
|
||||
</p>
|
||||
|
||||
<!-- CTA button -->
|
||||
<p style="text-align:center; margin:30px 0;">
|
||||
<a href="${signUrl}"
|
||||
style="display:inline-block;
|
||||
background-color:#007bff;
|
||||
color:#ffffff;
|
||||
text-decoration:none;
|
||||
padding:10px 20px;
|
||||
border-radius:5px;
|
||||
font-weight:bold;">
|
||||
Sign Your EOI
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<!-- closing -->
|
||||
<p style="font-size:16px;">
|
||||
Thank you,<br/>
|
||||
- The Port Nimara CRM
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// For testing, send to matt@portnimara.com
|
||||
return {
|
||||
to: 'matt@portnimara.com', // TODO: Change to recipient.email after testing
|
||||
subject: `EOI Signature Reminder - ${clientName}`,
|
||||
html
|
||||
};
|
||||
}
|
||||
|
||||
function generateSalesReminderEmail(interest: any, document: any): ReminderEmail {
|
||||
const clientName = interest['Full Name'] || 'Client';
|
||||
|
||||
const html = `<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--[if !mso]><!--><meta http-equiv="X-UA-Compatible" content="IE=edge" /><!--<![endif]-->
|
||||
<title>Port Nimara EOI Signature Reminder</title>
|
||||
<style type="text/css">
|
||||
table, td { mso-table-lspace:0pt; mso-table-rspace:0pt; }
|
||||
img { border:0; display:block; }
|
||||
p { margin:0; padding:0; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0; padding:0; background-color:#f2f2f2;">
|
||||
<table role="presentation" width="100%" border="0" cellspacing="0" cellpadding="0"
|
||||
style="background-image:url('https://s3.portnimara.com/images/Overhead_1_blur.png');
|
||||
background-size:cover;
|
||||
background-position:center;
|
||||
background-color:#f2f2f2;">
|
||||
<tr>
|
||||
<td align="center" style="padding:30px;">
|
||||
|
||||
<table role="presentation" width="600" border="0" cellspacing="0" cellpadding="0"
|
||||
style="background-color:#ffffff;
|
||||
border-radius:8px;
|
||||
overflow:hidden;
|
||||
box-shadow:0 2px 4px rgba(0,0,0,0.1);">
|
||||
<tr>
|
||||
<td style="padding:20px; font-family:Arial, sans-serif; color:#333333;">
|
||||
<!-- logo -->
|
||||
<center>
|
||||
<img
|
||||
src="https://s3.portnimara.com/images/Port%20Nimara%20New%20Logo-Circular%20Frame_250px.png"
|
||||
alt="Port Nimara Logo"
|
||||
width="100"
|
||||
style="margin-bottom:20px;"
|
||||
/>
|
||||
</center>
|
||||
|
||||
<!-- greeting & body -->
|
||||
<p style="margin-bottom:10px; font-size:16px;">
|
||||
Dear Sales Team,
|
||||
</p>
|
||||
<p style="margin-bottom:20px; font-size:16px;">
|
||||
The EOI for <strong>${clientName}</strong> has not been signed by the client yet.
|
||||
Please follow up with them to ensure the document is signed.
|
||||
Document: ${document.title}
|
||||
</p>
|
||||
|
||||
<!-- closing -->
|
||||
<p style="font-size:16px;">
|
||||
Thank you,<br/>
|
||||
- The Port Nimara CRM
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return {
|
||||
to: 'sales@portnimara.com',
|
||||
subject: `Action Required: EOI Not Signed - ${clientName}`,
|
||||
html
|
||||
};
|
||||
}
|
||||
|
||||
async function sendReminderEmail(email: ReminderEmail) {
|
||||
// Use noreply@portnimara.com credentials with correct mail server
|
||||
const credentials = {
|
||||
host: 'mail.portnimara.com',
|
||||
port: 465,
|
||||
secure: true,
|
||||
auth: {
|
||||
user: 'noreply@portnimara.com',
|
||||
pass: 'sJw6GW5G5bCI1EtBIq3J2hVm8xCOMw1kQs1puS6g0yABqkrwj'
|
||||
}
|
||||
};
|
||||
|
||||
// Send email using the existing email utility
|
||||
await sendEmail({
|
||||
from: 'Port Nimara CRM <noreply@portnimara.com>',
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
html: email.html
|
||||
}, credentials);
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import { uploadFile, createBucketIfNotExists, getMinioClient } from '~/server/utils/minio';
|
||||
import { updateInterestEOIDocument } from '~/server/utils/nocodb';
|
||||
import formidable from 'formidable';
|
||||
import { promises as fs } from 'fs';
|
||||
import mime from 'mime-types';
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const xTagHeader = getRequestHeader(event, "x-tag");
|
||||
|
||||
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
|
||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Get interestId from query params
|
||||
const query = getQuery(event);
|
||||
const interestId = query.interestId as string;
|
||||
|
||||
if (!interestId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Interest ID is required',
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure EOIs folder exists
|
||||
await createBucketIfNotExists('nda-documents');
|
||||
|
||||
// Parse multipart form data
|
||||
const form = formidable({
|
||||
maxFileSize: 50 * 1024 * 1024, // 50MB limit
|
||||
keepExtensions: true,
|
||||
});
|
||||
|
||||
const [fields, files] = await form.parse(event.node.req);
|
||||
|
||||
// Handle the uploaded file
|
||||
const uploadedFile = Array.isArray(files.file) ? files.file[0] : files.file;
|
||||
|
||||
if (!uploadedFile) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'No file uploaded',
|
||||
});
|
||||
}
|
||||
|
||||
// Read file buffer
|
||||
const fileBuffer = await fs.readFile(uploadedFile.filepath);
|
||||
|
||||
// Generate filename with timestamp
|
||||
const timestamp = Date.now();
|
||||
const sanitizedName = uploadedFile.originalFilename?.replace(/[^a-zA-Z0-9.-]/g, '_') || 'eoi-document.pdf';
|
||||
const fileName = `EOIs/${interestId}-${timestamp}-${sanitizedName}`;
|
||||
|
||||
// Get content type
|
||||
const contentType = mime.lookup(uploadedFile.originalFilename || '') || 'application/pdf';
|
||||
|
||||
// Upload to MinIO
|
||||
await uploadFile(fileName, fileBuffer, contentType);
|
||||
|
||||
// Clean up temp file
|
||||
await fs.unlink(uploadedFile.filepath);
|
||||
|
||||
// Get download URL for the uploaded file
|
||||
const client = getMinioClient();
|
||||
const url = await client.presignedGetObject('nda-documents', fileName, 24 * 60 * 60); // 24 hour expiry
|
||||
|
||||
// Prepare document data for database
|
||||
const documentData = {
|
||||
title: uploadedFile.originalFilename || 'EOI Document',
|
||||
filename: fileName,
|
||||
url: url,
|
||||
size: uploadedFile.size,
|
||||
uploadedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Update interest with EOI document information
|
||||
await updateInterestEOIDocument(interestId, documentData);
|
||||
|
||||
// Also update the status fields
|
||||
const updateData: any = {
|
||||
'EOI Status': 'Waiting for Signatures',
|
||||
'EOI Time Sent': new Date().toISOString()
|
||||
};
|
||||
|
||||
// Update Sales Process Level if it's below "LOI and NDA Sent"
|
||||
const currentLevel = await getCurrentSalesLevel(interestId);
|
||||
if (shouldUpdateSalesLevel(currentLevel)) {
|
||||
updateData['Sales Process Level'] = 'LOI and NDA Sent';
|
||||
}
|
||||
|
||||
// Update the interest
|
||||
await $fetch('/api/update-interest', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': xTagHeader,
|
||||
},
|
||||
body: {
|
||||
id: interestId,
|
||||
data: updateData
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
document: documentData,
|
||||
message: 'EOI document uploaded successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('Failed to upload EOI document:', error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: error.message || 'Failed to upload EOI document',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function getCurrentSalesLevel(interestId: string): Promise<string> {
|
||||
try {
|
||||
const interest = await $fetch(`/api/get-interest-by-id`, {
|
||||
headers: {
|
||||
'x-tag': '094ut234',
|
||||
},
|
||||
params: {
|
||||
id: interestId,
|
||||
},
|
||||
});
|
||||
return interest['Sales Process Level'] || '';
|
||||
} catch (error) {
|
||||
console.error('Failed to get current sales level:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUpdateSalesLevel(currentLevel: string): boolean {
|
||||
const levelsBeforeLOI = [
|
||||
'General Qualified Interest',
|
||||
'Specific Qualified Interest'
|
||||
];
|
||||
return levelsBeforeLOI.includes(currentLevel);
|
||||
}
|
||||
|
|
@ -12,6 +12,14 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Protect EOIs folder from deletion
|
||||
if (fileName === 'EOIs/' || fileName === 'EOIs') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'The EOIs folder is protected and cannot be deleted',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete folder or file based on type
|
||||
if (isFolder) {
|
||||
await deleteFolder(fileName);
|
||||
|
|
|
|||
|
|
@ -12,16 +12,44 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
// Get the download URL from MinIO
|
||||
const url = await getDownloadUrl(fileName);
|
||||
// Retry logic for getting download URL and fetching file
|
||||
let response: Response | null = null;
|
||||
let lastError: any = null;
|
||||
|
||||
// Fetch the file from MinIO
|
||||
const response = await fetch(url);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
console.log(`[proxy-download] Attempting to download ${fileName} (attempt ${attempt + 1}/3)`);
|
||||
|
||||
// Get the download URL from MinIO
|
||||
const url = await getDownloadUrl(fileName);
|
||||
|
||||
// Fetch the file from MinIO with timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.ok) {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
|
||||
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.error(`[proxy-download] Attempt ${attempt + 1} failed:`, error.message);
|
||||
|
||||
// Wait before retry with exponential backoff
|
||||
if (attempt < 2) {
|
||||
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response || !response.ok) {
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Failed to fetch file from storage',
|
||||
statusCode: response?.status || 500,
|
||||
statusMessage: lastError?.message || 'Failed to fetch file from storage after 3 attempts',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,15 @@ export default defineEventHandler(async (event) => {
|
|||
if (!oldName || !newName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Old name and new name are required',
|
||||
statusMessage: 'Both old and new names are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Protect EOIs folder from renaming
|
||||
if (oldName === 'EOIs/' || oldName === 'EOIs') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'The EOIs folder is protected and cannot be renamed',
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import { scheduleEOIReminders } from '~/server/tasks/eoi-reminders';
|
||||
import { scheduleEmailProcessing } from '~/server/tasks/process-sales-emails';
|
||||
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
// Schedule EOI reminders when server starts
|
||||
console.log('[Plugin] Initializing EOI reminder scheduler...');
|
||||
|
||||
// Add a small delay to ensure all services are ready
|
||||
setTimeout(() => {
|
||||
scheduleEOIReminders();
|
||||
}, 5000);
|
||||
|
||||
// Schedule email processing for EOI attachments
|
||||
console.log('[Plugin] Initializing email processing scheduler...');
|
||||
|
||||
setTimeout(() => {
|
||||
scheduleEmailProcessing();
|
||||
}, 7000);
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import cron from 'node-cron';
|
||||
import { getInterests } from '~/server/utils/nocodb';
|
||||
import { checkDocumentSignatureStatus } from '~/server/utils/documeso';
|
||||
|
||||
// Track if tasks are already scheduled
|
||||
let tasksScheduled = false;
|
||||
|
||||
export function scheduleEOIReminders() {
|
||||
if (tasksScheduled) {
|
||||
console.log('[EOI Reminders] Tasks already scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[EOI Reminders] Scheduling reminder tasks...');
|
||||
|
||||
// Schedule for 9am daily
|
||||
cron.schedule('0 9 * * *', async () => {
|
||||
console.log('[EOI Reminders] Running 9am reminder check...');
|
||||
await processReminders();
|
||||
}, {
|
||||
timezone: 'Europe/Paris'
|
||||
});
|
||||
|
||||
// Schedule for 4pm daily
|
||||
cron.schedule('0 16 * * *', async () => {
|
||||
console.log('[EOI Reminders] Running 4pm reminder check...');
|
||||
await processReminders();
|
||||
}, {
|
||||
timezone: 'Europe/Paris'
|
||||
});
|
||||
|
||||
tasksScheduled = true;
|
||||
console.log('[EOI Reminders] Tasks scheduled successfully');
|
||||
}
|
||||
|
||||
async function processReminders() {
|
||||
try {
|
||||
// Get all interests
|
||||
const response = await getInterests();
|
||||
const interests = response.list || [];
|
||||
|
||||
console.log(`[EOI Reminders] Processing ${interests.length} interests...`);
|
||||
|
||||
for (const interest of interests) {
|
||||
try {
|
||||
// Skip if no document ID or reminders disabled
|
||||
const documentId = (interest as any)['documeso_document_id'];
|
||||
const remindersEnabled = (interest as any)['reminder_enabled'] !== false;
|
||||
|
||||
if (!documentId || !remindersEnabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we should send reminder (not sent in last 12 hours)
|
||||
const lastReminderSent = (interest as any)['last_reminder_sent'];
|
||||
if (lastReminderSent) {
|
||||
const lastSentTime = new Date(lastReminderSent).getTime();
|
||||
const twelveHoursAgo = Date.now() - (12 * 60 * 60 * 1000);
|
||||
if (lastSentTime > twelveHoursAgo) {
|
||||
continue; // Skip if reminder sent within last 12 hours
|
||||
}
|
||||
}
|
||||
|
||||
// Send reminder
|
||||
await sendReminder(interest);
|
||||
} catch (error) {
|
||||
console.error(`[EOI Reminders] Error processing interest ${interest.Id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[EOI Reminders] Reminder processing completed');
|
||||
} catch (error) {
|
||||
console.error('[EOI Reminders] Error in processReminders:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReminder(interest: any) {
|
||||
try {
|
||||
const response = await $fetch<{
|
||||
success: boolean;
|
||||
remindersSent: number;
|
||||
results: any[];
|
||||
message?: string;
|
||||
}>('/api/eoi/send-reminders', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': '094ut234' // System tag for automated processes
|
||||
},
|
||||
body: {
|
||||
interestId: interest.Id.toString(),
|
||||
documentId: (interest as any)['documeso_document_id']
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log(`[EOI Reminders] Sent ${response.remindersSent} reminders for interest ${interest.Id}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[EOI Reminders] Failed to send reminder for interest ${interest.Id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export function to manually trigger reminders (for testing)
|
||||
export async function triggerReminders() {
|
||||
console.log('[EOI Reminders] Manually triggering reminder check...');
|
||||
await processReminders();
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// Task to process sales emails for EOI documents
|
||||
import { $fetch } from 'ofetch';
|
||||
|
||||
let taskScheduled = false;
|
||||
|
||||
export function scheduleEmailProcessing() {
|
||||
if (taskScheduled) {
|
||||
console.log('[Process Sales Emails] Task already scheduled');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Process Sales Emails] Scheduling email processing task...');
|
||||
|
||||
// Process emails every 30 minutes
|
||||
setInterval(async () => {
|
||||
console.log('[Process Sales Emails] Running email check...');
|
||||
await processEmails();
|
||||
}, 30 * 60 * 1000); // 30 minutes
|
||||
|
||||
// Also run immediately on startup
|
||||
setTimeout(() => {
|
||||
processEmails();
|
||||
}, 10000); // 10 seconds after startup
|
||||
|
||||
taskScheduled = true;
|
||||
console.log('[Process Sales Emails] Task scheduled successfully');
|
||||
}
|
||||
|
||||
async function processEmails() {
|
||||
try {
|
||||
const response = await $fetch('/api/email/process-sales-eois', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-tag': '094ut234' // System tag for automated processes
|
||||
}
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log(`[Process Sales Emails] Processed ${response.processed} emails`);
|
||||
if (response.results && response.results.length > 0) {
|
||||
response.results.forEach((result: any) => {
|
||||
if (result.processed) {
|
||||
console.log(`[Process Sales Emails] Successfully processed EOI for ${result.clientName}`);
|
||||
} else {
|
||||
console.log(`[Process Sales Emails] Failed to process EOI: ${result.error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Process Sales Emails] Error processing emails:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export function to manually trigger processing (for testing)
|
||||
export async function triggerEmailProcessing() {
|
||||
console.log('[Process Sales Emails] Manually triggering email processing...');
|
||||
await processEmails();
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
// Documeso API client utilities
|
||||
interface DocumesoConfig {
|
||||
apiUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
interface DocumesoRecipient {
|
||||
id: number;
|
||||
documentId: number;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'SIGNER' | 'APPROVER' | 'VIEWER';
|
||||
signingOrder: number;
|
||||
token: string;
|
||||
signedAt: string | null;
|
||||
readStatus: 'NOT_OPENED' | 'OPENED';
|
||||
signingStatus: 'NOT_SIGNED' | 'SIGNED';
|
||||
sendStatus: 'NOT_SENT' | 'SENT';
|
||||
signingUrl: string;
|
||||
}
|
||||
|
||||
interface DocumesoDocument {
|
||||
id: number;
|
||||
externalId: string;
|
||||
userId: number;
|
||||
teamId: number;
|
||||
title: string;
|
||||
status: 'DRAFT' | 'PENDING' | 'COMPLETED' | 'CANCELLED';
|
||||
documentDataId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
recipients: DocumesoRecipient[];
|
||||
}
|
||||
|
||||
interface DocumesoListResponse {
|
||||
documents: DocumesoDocument[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
}
|
||||
|
||||
// Get Documeso configuration
|
||||
const getDocumesoConfig = (): DocumesoConfig => {
|
||||
return {
|
||||
apiUrl: 'https://signatures.portnimara.dev/api/v1',
|
||||
apiKey: 'Bearer api_malptg62zqyb0wrp'
|
||||
};
|
||||
};
|
||||
|
||||
// Fetch a single document by ID
|
||||
export const getDocumesoDocument = async (documentId: number): Promise<DocumesoDocument> => {
|
||||
const config = getDocumesoConfig();
|
||||
|
||||
try {
|
||||
const response = await $fetch<DocumesoDocument>(`${config.apiUrl}/documents/${documentId}`, {
|
||||
headers: {
|
||||
'Authorization': config.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Documeso document:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Search documents by external ID (e.g., 'loi-94')
|
||||
export const searchDocumesoDocuments = async (externalId?: string): Promise<DocumesoDocument[]> => {
|
||||
const config = getDocumesoConfig();
|
||||
|
||||
try {
|
||||
const response = await $fetch<DocumesoListResponse>(`${config.apiUrl}/documents`, {
|
||||
headers: {
|
||||
'Authorization': config.apiKey,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
params: {
|
||||
perPage: 100
|
||||
}
|
||||
});
|
||||
|
||||
// If externalId is provided, filter by it
|
||||
if (externalId) {
|
||||
return response.documents.filter(doc => doc.externalId === externalId);
|
||||
}
|
||||
|
||||
return response.documents;
|
||||
} catch (error) {
|
||||
console.error('Failed to search Documeso documents:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Get document by external ID (e.g., 'loi-94')
|
||||
export const getDocumesoDocumentByExternalId = async (externalId: string): Promise<DocumesoDocument | null> => {
|
||||
const documents = await searchDocumesoDocuments(externalId);
|
||||
return documents.length > 0 ? documents[0] : null;
|
||||
};
|
||||
|
||||
// Check signature status for a document
|
||||
export const checkDocumentSignatureStatus = async (documentId: number): Promise<{
|
||||
documentStatus: string;
|
||||
unsignedRecipients: DocumesoRecipient[];
|
||||
signedRecipients: DocumesoRecipient[];
|
||||
clientSigned: boolean;
|
||||
allSigned: boolean;
|
||||
}> => {
|
||||
const document = await getDocumesoDocument(documentId);
|
||||
|
||||
const unsignedRecipients = document.recipients.filter(r => r.signingStatus === 'NOT_SIGNED');
|
||||
const signedRecipients = document.recipients.filter(r => r.signingStatus === 'SIGNED');
|
||||
|
||||
// Check if client (signingOrder = 1) has signed
|
||||
const clientRecipient = document.recipients.find(r => r.signingOrder === 1);
|
||||
const clientSigned = clientRecipient ? clientRecipient.signingStatus === 'SIGNED' : false;
|
||||
|
||||
const allSigned = unsignedRecipients.length === 0;
|
||||
|
||||
return {
|
||||
documentStatus: document.status,
|
||||
unsignedRecipients,
|
||||
signedRecipients,
|
||||
clientSigned,
|
||||
allSigned
|
||||
};
|
||||
};
|
||||
|
||||
// Get recipients who need to sign (excluding client)
|
||||
export const getRecipientsToRemind = async (documentId: number): Promise<DocumesoRecipient[]> => {
|
||||
const status = await checkDocumentSignatureStatus(documentId);
|
||||
|
||||
// Only remind if client has signed
|
||||
if (!status.clientSigned) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return unsigned recipients with signingOrder > 1
|
||||
return status.unsignedRecipients.filter(r => r.signingOrder > 1);
|
||||
};
|
||||
|
||||
// Format recipient name for emails
|
||||
export const formatRecipientName = (recipient: DocumesoRecipient): string => {
|
||||
const firstName = recipient.name.split(' ')[0];
|
||||
return firstName;
|
||||
};
|
||||
|
||||
// Get signing URL for a recipient
|
||||
export const getSigningUrl = (recipient: DocumesoRecipient): string => {
|
||||
return recipient.signingUrl;
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { simpleParser } from 'mailparser';
|
||||
import type { ParsedMail } from 'mailparser';
|
||||
import Imap from 'imap';
|
||||
|
||||
export type { ParsedMail };
|
||||
|
||||
export interface EmailCredentials {
|
||||
user: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
}
|
||||
|
||||
export async function parseEmail(emailContent: string): Promise<ParsedMail> {
|
||||
return await simpleParser(emailContent);
|
||||
}
|
||||
|
||||
export function getIMAPConnection(credentials: EmailCredentials): Promise<Imap> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = new Imap({
|
||||
user: credentials.user,
|
||||
password: credentials.password,
|
||||
host: credentials.host,
|
||||
port: credentials.port,
|
||||
tls: credentials.tls,
|
||||
tlsOptions: { rejectUnauthorized: false }
|
||||
});
|
||||
|
||||
imap.once('ready', () => {
|
||||
console.log('[IMAP] Connection ready');
|
||||
resolve(imap);
|
||||
});
|
||||
|
||||
imap.once('error', (err: Error) => {
|
||||
console.error('[IMAP] Connection error:', err);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
||||
export function searchEmails(imap: Imap, criteria: any[]): Promise<number[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
imap.search(criteria, (err: Error | null, results: number[]) => {
|
||||
if (err) reject(err);
|
||||
else resolve(results || []);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchEmail(imap: Imap, msgId: number, options: any): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let emailData = '';
|
||||
|
||||
const fetch = imap.fetch(msgId, options);
|
||||
|
||||
fetch.on('message', (msg: any) => {
|
||||
msg.on('body', (stream: any) => {
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
emailData += chunk.toString();
|
||||
});
|
||||
|
||||
stream.once('end', () => {
|
||||
resolve(emailData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (err: Error) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
if (!emailData) {
|
||||
reject(new Error('No email data received'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
|
||||
interface EmailOptions {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
html?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
interface SmtpConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
secure: boolean;
|
||||
auth: {
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendEmail(options: EmailOptions, config?: SmtpConfig) {
|
||||
// Use provided config or default to environment config
|
||||
const smtpConfig = config || {
|
||||
host: process.env.SMTP_HOST || 'mail.portnimara.com',
|
||||
port: parseInt(process.env.SMTP_PORT || '465'),
|
||||
secure: process.env.SMTP_SECURE !== 'false',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER || '',
|
||||
pass: process.env.SMTP_PASS || ''
|
||||
}
|
||||
};
|
||||
|
||||
// Create transporter
|
||||
const transporter = nodemailer.createTransport(smtpConfig);
|
||||
|
||||
// Send email
|
||||
try {
|
||||
const info = await transporter.sendMail(options);
|
||||
console.log('Email sent:', info.messageId);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error('Failed to send email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -297,3 +297,21 @@ export const renameFolder = async (oldPath: string, newPath: string) => {
|
|||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Create bucket if it doesn't exist
|
||||
export const createBucketIfNotExists = async (bucketName?: string) => {
|
||||
const client = getMinioClient();
|
||||
const bucket = bucketName || useRuntimeConfig().minio.bucketName;
|
||||
|
||||
try {
|
||||
const exists = await client.bucketExists(bucket);
|
||||
if (!exists) {
|
||||
await client.makeBucket(bucket);
|
||||
console.log(`Bucket '${bucket}' created successfully`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -299,3 +299,34 @@ export const triggerWebhook = async (url: string, payload: any) =>
|
|||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
|
||||
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
|
||||
|
||||
// Get existing EOI Document array or create new one
|
||||
const interest = await getInterestById(id);
|
||||
const existingDocuments = interest['EOI Document'] || [];
|
||||
|
||||
// Add the new document to the array
|
||||
const updatedDocuments = [...existingDocuments, documentData];
|
||||
|
||||
// Update the interest with the new EOI Document array
|
||||
return updateInterest(id, {
|
||||
'EOI Document': updatedDocuments
|
||||
});
|
||||
};
|
||||
|
||||
export const getInterestByFieldAsync = async (fieldName: string, value: any): Promise<Interest | null> => {
|
||||
try {
|
||||
const response = await getInterests();
|
||||
const interests = response.list || [];
|
||||
|
||||
// Find interest where the field matches the value
|
||||
const interest = interests.find(i => (i as any)[fieldName] === value);
|
||||
|
||||
return interest || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching interest by field:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue