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>
|
||||||
|
|
@ -6,8 +6,30 @@
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text class="pt-0">
|
<v-card-text class="pt-0">
|
||||||
|
|
||||||
<!-- Generate EOI Button -->
|
<!-- EOI Documents Section -->
|
||||||
<div v-if="!hasEOI" class="mb-4">
|
<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
|
<v-btn
|
||||||
@click="generateEOI"
|
@click="generateEOI"
|
||||||
:loading="isGenerating"
|
:loading="isGenerating"
|
||||||
|
|
@ -19,6 +41,18 @@
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</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 -->
|
<!-- EOI Status Badge -->
|
||||||
<div v-if="hasEOI" class="mb-4 d-flex align-center">
|
<div v-if="hasEOI" class="mb-4 d-flex align-center">
|
||||||
<v-chip
|
<v-chip
|
||||||
|
|
@ -100,6 +134,37 @@
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -115,8 +180,11 @@ const emit = defineEmits<{
|
||||||
'update': [];
|
'update': [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const { showToast } = useToast();
|
const toast = useToast();
|
||||||
const isGenerating = ref(false);
|
const isGenerating = ref(false);
|
||||||
|
const showUploadDialog = ref(false);
|
||||||
|
const isUploading = ref(false);
|
||||||
|
const selectedFile = ref<File | null>(null);
|
||||||
|
|
||||||
const hasEOI = computed(() => {
|
const hasEOI = computed(() => {
|
||||||
return !!(props.interest['Signature Link Client'] ||
|
return !!(props.interest['Signature Link Client'] ||
|
||||||
|
|
@ -124,7 +192,15 @@ const hasEOI = computed(() => {
|
||||||
props.interest['Signature Link Developer']);
|
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;
|
isGenerating.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -144,16 +220,27 @@ const generateEOI = async () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
showToast(response.documentId === 'existing'
|
toast.success(response.documentId === 'existing'
|
||||||
? 'EOI already exists - signature links retrieved'
|
? 'EOI already exists - signature links retrieved'
|
||||||
: 'EOI generated successfully');
|
: 'EOI generated successfully');
|
||||||
|
|
||||||
emit('eoi-generated', { signingLinks: response.signingLinks });
|
emit('eoi-generated', { signingLinks: response.signingLinks });
|
||||||
emit('update'); // Trigger parent to refresh data
|
emit('update'); // Trigger parent to refresh data
|
||||||
|
} else {
|
||||||
|
throw new Error('EOI generation failed');
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Failed to generate EOI:', error);
|
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 {
|
} finally {
|
||||||
isGenerating.value = false;
|
isGenerating.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +251,7 @@ const copyLink = async (link: string | undefined) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(link);
|
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
|
// Update EOI Time Sent if not already set
|
||||||
if (!props.interest['EOI Time Sent']) {
|
if (!props.interest['EOI Time Sent']) {
|
||||||
|
|
@ -187,7 +274,7 @@ const copyLink = async (link: string | undefined) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('Failed to copy link');
|
toast.error('Failed to copy link');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -225,4 +312,45 @@ const getStatusColor = (status: string) => {
|
||||||
return 'grey';
|
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>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -184,14 +184,12 @@
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|
||||||
<v-card-text class="pa-0">
|
<v-card-text class="pa-0" style="height: 70vh; overflow-y: auto;">
|
||||||
<div style="height: 600px; overflow: hidden;">
|
<file-browser-component
|
||||||
<file-browser-component
|
v-if="showFileBrowser"
|
||||||
v-if="showFileBrowser"
|
:selection-mode="true"
|
||||||
:selection-mode="true"
|
@file-selected="onFileSelected"
|
||||||
@file-selected="onFileSelected"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,6 @@
|
||||||
v-if="interest['EOI Time Sent']"
|
v-if="interest['EOI Time Sent']"
|
||||||
color="warning"
|
color="warning"
|
||||||
variant="tonal"
|
variant="tonal"
|
||||||
prepend-icon="mdi-email-fast"
|
|
||||||
>
|
>
|
||||||
EOI Sent: {{ formatDate(interest["EOI Time Sent"]) }}
|
EOI Sent: {{ formatDate(interest["EOI Time Sent"]) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
|
|
@ -624,10 +623,11 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- Email Communication Section -->
|
<!-- Email Communication Section -->
|
||||||
<EmailCommunication
|
<ClientEmailSection
|
||||||
v-if="interest"
|
v-if="interest"
|
||||||
:interest="interest"
|
:interest="interest"
|
||||||
@interestUpdated="onInterestUpdated"
|
@email-sent="onInterestUpdated"
|
||||||
|
@update="onInterestUpdated"
|
||||||
/>
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
@ -658,7 +658,7 @@ function debounce<T extends (...args: any[]) => any>(
|
||||||
return debounced;
|
return debounced;
|
||||||
}
|
}
|
||||||
import PhoneInput from "./PhoneInput.vue";
|
import PhoneInput from "./PhoneInput.vue";
|
||||||
import EmailCommunication from "./EmailCommunication.vue";
|
import ClientEmailSection from "./ClientEmailSection.vue";
|
||||||
import EOISection from "./EOISection.vue";
|
import EOISection from "./EOISection.vue";
|
||||||
import {
|
import {
|
||||||
InterestSalesProcessLevelFlow,
|
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,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
"mailparser": "^3.7.3",
|
"mailparser": "^3.7.3",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
|
"node-cron": "^4.1.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
|
@ -23,8 +25,10 @@
|
||||||
"vuetify-nuxt-module": "^0.18.3"
|
"vuetify-nuxt-module": "^0.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/formidable": "^3.4.5",
|
||||||
"@types/imap": "^0.8.42",
|
"@types/imap": "^0.8.42",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/nodemailer": "^6.4.17"
|
"@types/nodemailer": "^6.4.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -3693,6 +3697,16 @@
|
||||||
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/http-proxy": {
|
||||||
"version": "1.17.15",
|
"version": "1.17.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz",
|
||||||
|
|
@ -3738,6 +3752,13 @@
|
||||||
"iconv-lite": "^0.6.3"
|
"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": {
|
"node_modules/@types/node": {
|
||||||
"version": "22.12.0",
|
"version": "22.12.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
|
||||||
|
|
@ -3747,6 +3768,12 @@
|
||||||
"undici-types": "~6.20.0"
|
"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": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.17",
|
"version": "6.4.17",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||||
|
|
@ -9446,6 +9473,15 @@
|
||||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/node-fetch": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node-cron": "^3.0.11",
|
||||||
"@vite-pwa/nuxt": "^0.10.6",
|
"@vite-pwa/nuxt": "^0.10.6",
|
||||||
"formidable": "^3.5.4",
|
"formidable": "^3.5.4",
|
||||||
"imap": "^0.8.19",
|
"imap": "^0.8.19",
|
||||||
|
|
@ -16,6 +17,7 @@
|
||||||
"mailparser": "^3.7.3",
|
"mailparser": "^3.7.3",
|
||||||
"mime-types": "^3.0.1",
|
"mime-types": "^3.0.1",
|
||||||
"minio": "^8.0.5",
|
"minio": "^8.0.5",
|
||||||
|
"node-cron": "^4.1.0",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "^7.0.3",
|
||||||
"nuxt": "^3.15.4",
|
"nuxt": "^3.15.4",
|
||||||
"nuxt-directus": "^5.7.0",
|
"nuxt-directus": "^5.7.0",
|
||||||
|
|
@ -25,8 +27,10 @@
|
||||||
"vuetify-nuxt-module": "^0.18.3"
|
"vuetify-nuxt-module": "^0.18.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/formidable": "^3.4.5",
|
||||||
"@types/imap": "^0.8.42",
|
"@types/imap": "^0.8.42",
|
||||||
"@types/mailparser": "^3.4.6",
|
"@types/mailparser": "^3.4.6",
|
||||||
|
"@types/mime-types": "^3.0.1",
|
||||||
"@types/nodemailer": "^6.4.17"
|
"@types/nodemailer": "^6.4.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pa-6">
|
<v-container fluid class="pa-6">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<v-row class="mb-6">
|
<v-row class="mb-6" v-if="!props.selectionMode">
|
||||||
<v-col>
|
<v-col>
|
||||||
<h1 class="text-h4 font-weight-bold">
|
<h1 class="text-h4 font-weight-bold">
|
||||||
File Browser
|
File Browser
|
||||||
|
|
@ -12,14 +12,26 @@
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</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 -->
|
<!-- Breadcrumb Navigation -->
|
||||||
<v-row class="mb-4" v-if="currentPath">
|
<v-row class="mb-4" v-if="currentPath && !props.selectionMode">
|
||||||
<v-col>
|
<v-col>
|
||||||
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
|
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
|
||||||
<template v-slot:item="{ item }">
|
<template v-slot:item="{ item }">
|
||||||
<v-breadcrumbs-item
|
<v-breadcrumbs-item
|
||||||
:to="item.to"
|
@click="navigateToFolder((item as any).path)"
|
||||||
@click="navigateToFolder(item.path)"
|
class="cursor-pointer"
|
||||||
>
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</v-breadcrumbs-item>
|
</v-breadcrumbs-item>
|
||||||
|
|
@ -41,7 +53,7 @@
|
||||||
@update:model-value="filterFiles"
|
@update:model-value="filterFiles"
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</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
|
<v-btn
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="large"
|
size="large"
|
||||||
|
|
@ -63,7 +75,7 @@
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- Bulk Actions Bar (shown when items selected) -->
|
<!-- 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-col>
|
||||||
<v-alert
|
<v-alert
|
||||||
type="info"
|
type="info"
|
||||||
|
|
@ -312,7 +324,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
import FileUploader from '~/components/FileUploader.vue';
|
import FileUploader from '~/components/FileUploader.vue';
|
||||||
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
import FilePreviewModal from '~/components/FilePreviewModal.vue';
|
||||||
|
|
||||||
|
|
@ -325,8 +337,20 @@ interface FileItem {
|
||||||
icon: string;
|
icon: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
isFolder: boolean;
|
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();
|
const toast = useToast();
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
|
|
@ -383,22 +407,42 @@ const breadcrumbItems = computed(() => {
|
||||||
return items;
|
return items;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load files
|
// Load files with retry logic
|
||||||
const loadFiles = async () => {
|
const loadFiles = async (retryCount = 0) => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await $fetch('/api/files/list', {
|
const response = await $fetch('/api/files/list', {
|
||||||
params: {
|
params: {
|
||||||
prefix: currentPath.value,
|
prefix: currentPath.value,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
}
|
},
|
||||||
|
timeout: 15000 // 15 second timeout
|
||||||
});
|
});
|
||||||
files.value = response.files;
|
files.value = response.files || [];
|
||||||
filteredFiles.value = response.files;
|
filteredFiles.value = response.files || [];
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
toast.error('Failed to load files');
|
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 {
|
} finally {
|
||||||
loading.value = false;
|
if (retryCount === 0 || retryCount === 2) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -417,6 +461,15 @@ const filterFiles = () => {
|
||||||
|
|
||||||
// Handle file/folder click
|
// Handle file/folder click
|
||||||
const handleFileClick = (item: FileItem) => {
|
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) {
|
if (item.isFolder) {
|
||||||
navigateToFolder(item.name);
|
navigateToFolder(item.name);
|
||||||
} else if (canPreview(item)) {
|
} else if (canPreview(item)) {
|
||||||
|
|
|
||||||
|
|
@ -388,40 +388,81 @@ async function fetchImapEmails(
|
||||||
// Group emails into threads based on subject and references
|
// Group emails into threads based on subject and references
|
||||||
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
||||||
const threads = new Map<string, EmailMessage[]>();
|
const threads = new Map<string, EmailMessage[]>();
|
||||||
|
const emailById = new Map<string, EmailMessage>();
|
||||||
|
|
||||||
|
// First pass: index all emails by ID
|
||||||
emails.forEach(email => {
|
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
|
const normalizedSubject = email.subject
|
||||||
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
|
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
|
||||||
.trim();
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
// Find existing thread or create new one
|
// Check if this email belongs to an existing thread
|
||||||
let threadFound = false;
|
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) {
|
// First, check if it has a threadId (in-reply-to header)
|
||||||
threadEmails.push(email);
|
if (email.threadId) {
|
||||||
threadFound = true;
|
// Look for the parent email
|
||||||
break;
|
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) {
|
if (!threadFound) {
|
||||||
threads.set(email.id, [email]);
|
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())
|
return Array.from(threads.entries())
|
||||||
.map(([threadId, emails]) => ({
|
.map(([threadId, threadEmails]) => {
|
||||||
id: threadId,
|
// Sort emails within thread by timestamp (oldest first for chronological order)
|
||||||
subject: emails[0].subject,
|
threadEmails.sort((a, b) =>
|
||||||
emailCount: emails.length,
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||||
latestTimestamp: emails[0].timestamp, // First email is newest since we sorted desc
|
);
|
||||||
emails: emails
|
|
||||||
}))
|
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());
|
.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) => {
|
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);
|
const imap = new Imap(imapConfig);
|
||||||
|
|
||||||
// Add a timeout to prevent hanging
|
// Add a timeout to prevent hanging
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
console.error('[test-connection] IMAP connection timeout');
|
console.error('[test-connection] IMAP connection timeout');
|
||||||
imap.end();
|
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', () => {
|
imap.once('ready', () => {
|
||||||
console.log('[test-connection] IMAP connection successful');
|
console.log('[test-connection] IMAP connection successful');
|
||||||
|
|
@ -88,7 +99,25 @@ export default defineEventHandler(async (event) => {
|
||||||
imap.once('error', (err: Error) => {
|
imap.once('error', (err: Error) => {
|
||||||
console.error('[test-connection] IMAP connection error:', err);
|
console.error('[test-connection] IMAP connection error:', err);
|
||||||
clearTimeout(timeout);
|
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();
|
imap.connect();
|
||||||
|
|
@ -98,7 +127,7 @@ export default defineEventHandler(async (event) => {
|
||||||
try {
|
try {
|
||||||
await testImapConnection();
|
await testImapConnection();
|
||||||
} catch (imapError: any) {
|
} 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}`);
|
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
|
// Delete folder or file based on type
|
||||||
if (isFolder) {
|
if (isFolder) {
|
||||||
await deleteFolder(fileName);
|
await deleteFolder(fileName);
|
||||||
|
|
|
||||||
|
|
@ -12,16 +12,44 @@ export default defineEventHandler(async (event) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the download URL from MinIO
|
// Retry logic for getting download URL and fetching file
|
||||||
const url = await getDownloadUrl(fileName);
|
let response: Response | null = null;
|
||||||
|
let lastError: any = null;
|
||||||
|
|
||||||
// Fetch the file from MinIO
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const response = await fetch(url);
|
try {
|
||||||
|
console.log(`[proxy-download] Attempting to download ${fileName} (attempt ${attempt + 1}/3)`);
|
||||||
|
|
||||||
if (!response.ok) {
|
// 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 || !response.ok) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: response.status,
|
statusCode: response?.status || 500,
|
||||||
statusMessage: 'Failed to fetch file from storage',
|
statusMessage: lastError?.message || 'Failed to fetch file from storage after 3 attempts',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,15 @@ export default defineEventHandler(async (event) => {
|
||||||
if (!oldName || !newName) {
|
if (!oldName || !newName) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
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",
|
method: "POST",
|
||||||
body: payload,
|
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