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:
Matt 2025-06-10 13:59:09 +02:00
parent 5c30411c2b
commit 218705da52
25 changed files with 2351 additions and 71 deletions

View File

@ -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>

View File

@ -5,9 +5,31 @@
EOI Management
</v-card-title>
<v-card-text class="pt-0">
<!-- Generate EOI Button -->
<div v-if="!hasEOI" class="mb-4">
<!-- EOI Documents Section -->
<div v-if="hasEOIDocuments" class="mb-4">
<div class="text-subtitle-1 mb-2">EOI Documents</div>
<div class="d-flex flex-wrap ga-2">
<v-chip
v-for="(doc, index) in eoiDocuments"
:key="index"
color="primary"
variant="tonal"
prepend-icon="mdi-file-pdf-box"
:href="doc.url"
target="_blank"
component="a"
clickable
closable
@click:close="removeDocument(index)"
>
{{ doc.title || `EOI Document ${index + 1}` }}
</v-chip>
</div>
</div>
<!-- Generate EOI Button - Only show if no documents uploaded -->
<div v-if="!hasEOI && !hasEOIDocuments" class="mb-4">
<v-btn
@click="generateEOI"
:loading="isGenerating"
@ -18,6 +40,18 @@
Generate EOI
</v-btn>
</div>
<!-- Upload EOI Button -->
<div class="mb-4">
<v-btn
@click="showUploadDialog = true"
variant="outlined"
prepend-icon="mdi-upload"
:disabled="isUploading"
>
{{ hasEOI ? 'Upload Signed EOI' : 'Upload EOI Document' }}
</v-btn>
</div>
<!-- EOI Status Badge -->
<div v-if="hasEOI" class="mb-4 d-flex align-center">
@ -100,6 +134,37 @@
</v-btn>
</div>
</v-card-text>
<!-- Upload Dialog -->
<v-dialog v-model="showUploadDialog" max-width="500">
<v-card>
<v-card-title>Upload EOI Document</v-card-title>
<v-card-text>
<v-file-input
v-model="selectedFile"
label="Select EOI document (PDF)"
accept=".pdf"
prepend-icon="mdi-file-pdf-box"
variant="outlined"
density="comfortable"
:rules="[v => !!v || 'Please select a file']"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showUploadDialog = false">Cancel</v-btn>
<v-btn
color="primary"
variant="flat"
@click="handleUpload"
:loading="isUploading"
:disabled="!selectedFile"
>
Upload
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
@ -115,8 +180,11 @@ const emit = defineEmits<{
'update': [];
}>();
const { showToast } = useToast();
const toast = useToast();
const isGenerating = ref(false);
const showUploadDialog = ref(false);
const isUploading = ref(false);
const selectedFile = ref<File | null>(null);
const hasEOI = computed(() => {
return !!(props.interest['Signature Link Client'] ||
@ -124,7 +192,15 @@ const hasEOI = computed(() => {
props.interest['Signature Link Developer']);
});
const generateEOI = async () => {
const eoiDocuments = computed(() => {
return props.interest['EOI Document'] || [];
});
const hasEOIDocuments = computed(() => {
return eoiDocuments.value.length > 0;
});
const generateEOI = async (retryCount = 0) => {
isGenerating.value = true;
try {
@ -144,16 +220,27 @@ const generateEOI = async () => {
});
if (response.success) {
showToast(response.documentId === 'existing'
toast.success(response.documentId === 'existing'
? 'EOI already exists - signature links retrieved'
: 'EOI generated successfully');
emit('eoi-generated', { signingLinks: response.signingLinks });
emit('update'); // Trigger parent to refresh data
} else {
throw new Error('EOI generation failed');
}
} catch (error: any) {
console.error('Failed to generate EOI:', error);
showToast(error.data?.statusMessage || 'Failed to generate EOI');
// Retry logic
if (retryCount < 3) {
console.log(`Retrying EOI generation... Attempt ${retryCount + 2}/4`);
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
return generateEOI(retryCount + 1);
}
// Show error message after all retries failed
toast.error(error.data?.statusMessage || error.message || 'Failed to generate EOI after multiple attempts');
} finally {
isGenerating.value = false;
}
@ -164,7 +251,7 @@ const copyLink = async (link: string | undefined) => {
try {
await navigator.clipboard.writeText(link);
showToast('Signature link copied to clipboard');
toast.success('Signature link copied to clipboard');
// Update EOI Time Sent if not already set
if (!props.interest['EOI Time Sent']) {
@ -187,7 +274,7 @@ const copyLink = async (link: string | undefined) => {
}
}
} catch (error) {
showToast('Failed to copy link');
toast.error('Failed to copy link');
}
};
@ -225,4 +312,45 @@ const getStatusColor = (status: string) => {
return 'grey';
}
};
const removeDocument = async (index: number) => {
// For now, we'll just show a message since removing documents
// would require updating the database
toast.warning('Document removal is not yet implemented');
};
const uploadEOI = async (file: File) => {
isUploading.value = true;
try {
const formData = new FormData();
formData.append('file', file);
const response = await $fetch<{ success: boolean; document: any; message: string }>('/api/eoi/upload-document', {
method: 'POST',
query: {
interestId: props.interest.Id.toString()
},
body: formData
});
if (response.success) {
toast.success('EOI document uploaded successfully');
showUploadDialog.value = false;
selectedFile.value = null; // Reset file selection
emit('update'); // Refresh parent data
}
} catch (error: any) {
console.error('Failed to upload EOI:', error);
toast.error(error.data?.statusMessage || 'Failed to upload EOI document');
} finally {
isUploading.value = false;
}
};
const handleUpload = () => {
if (selectedFile.value) {
uploadEOI(selectedFile.value);
}
};
</script>

View File

@ -184,14 +184,12 @@
</v-btn>
</v-toolbar>
<v-card-text class="pa-0">
<div style="height: 600px; overflow: hidden;">
<file-browser-component
v-if="showFileBrowser"
:selection-mode="true"
@file-selected="onFileSelected"
/>
</div>
<v-card-text class="pa-0" style="height: 70vh; overflow-y: auto;">
<file-browser-component
v-if="showFileBrowser"
:selection-mode="true"
@file-selected="onFileSelected"
/>
</v-card-text>
<v-divider />

View File

@ -131,7 +131,6 @@
v-if="interest['EOI Time Sent']"
color="warning"
variant="tonal"
prepend-icon="mdi-email-fast"
>
EOI Sent: {{ formatDate(interest["EOI Time Sent"]) }}
</v-chip>
@ -624,10 +623,11 @@
</v-card>
<!-- Email Communication Section -->
<EmailCommunication
<ClientEmailSection
v-if="interest"
:interest="interest"
@interestUpdated="onInterestUpdated"
@email-sent="onInterestUpdated"
@update="onInterestUpdated"
/>
</v-form>
</v-card-text>
@ -658,7 +658,7 @@ function debounce<T extends (...args: any[]) => any>(
return debounced;
}
import PhoneInput from "./PhoneInput.vue";
import EmailCommunication from "./EmailCommunication.vue";
import ClientEmailSection from "./ClientEmailSection.vue";
import EOISection from "./EOISection.vue";
import {
InterestSalesProcessLevelFlow,

View File

@ -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

36
package-lock.json generated
View File

@ -7,6 +7,7 @@
"hasInstallScript": true,
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node-cron": "^3.0.11",
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"imap": "^0.8.19",
@ -14,6 +15,7 @@
"mailparser": "^3.7.3",
"mime-types": "^3.0.1",
"minio": "^8.0.5",
"node-cron": "^4.1.0",
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
@ -23,8 +25,10 @@
"vuetify-nuxt-module": "^0.18.3"
},
"devDependencies": {
"@types/formidable": "^3.4.5",
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17"
}
},
@ -3693,6 +3697,16 @@
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"license": "MIT"
},
"node_modules/@types/formidable": {
"version": "3.4.5",
"resolved": "https://registry.npmjs.org/@types/formidable/-/formidable-3.4.5.tgz",
"integrity": "sha512-s7YPsNVfnsng5L8sKnG/Gbb2tiwwJTY1conOkJzTMRvJAlLFW1nEua+ADsJQu8N1c0oTHx9+d5nqg10WuT9gHQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/http-proxy": {
"version": "1.17.15",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.15.tgz",
@ -3738,6 +3752,13 @@
"iconv-lite": "^0.6.3"
}
},
"node_modules/@types/mime-types": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-3.0.1.tgz",
"integrity": "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.12.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.12.0.tgz",
@ -3747,6 +3768,12 @@
"undici-types": "~6.20.0"
}
},
"node_modules/@types/node-cron": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz",
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "6.4.17",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
@ -9446,6 +9473,15 @@
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
"license": "MIT"
},
"node_modules/node-cron": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.1.0.tgz",
"integrity": "sha512-OS+3ORu+h03/haS6Di8Qr7CrVs4YaKZZOynZwQpyPZDnR3tqRbwJmuP2gVR16JfhLgyNlloAV1VTrrWlRogCFA==",
"license": "ISC",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",

View File

@ -9,6 +9,7 @@
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@types/node-cron": "^3.0.11",
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"imap": "^0.8.19",
@ -16,6 +17,7 @@
"mailparser": "^3.7.3",
"mime-types": "^3.0.1",
"minio": "^8.0.5",
"node-cron": "^4.1.0",
"nodemailer": "^7.0.3",
"nuxt": "^3.15.4",
"nuxt-directus": "^5.7.0",
@ -25,8 +27,10 @@
"vuetify-nuxt-module": "^0.18.3"
},
"devDependencies": {
"@types/formidable": "^3.4.5",
"@types/imap": "^0.8.42",
"@types/mailparser": "^3.4.6",
"@types/mime-types": "^3.0.1",
"@types/nodemailer": "^6.4.17"
}
}

View File

@ -1,7 +1,7 @@
<template>
<v-container fluid class="pa-6">
<!-- Header -->
<v-row class="mb-6">
<v-row class="mb-6" v-if="!props.selectionMode">
<v-col>
<h1 class="text-h4 font-weight-bold">
File Browser
@ -12,14 +12,26 @@
</v-col>
</v-row>
<!-- Selection Mode Header -->
<v-row v-if="props.selectionMode" class="mb-4">
<v-col>
<h2 class="text-h5 font-weight-bold">
Select Files to Attach
</h2>
<p class="text-subtitle-2 text-grey mt-1">
Click on files to attach them to your email
</p>
</v-col>
</v-row>
<!-- Breadcrumb Navigation -->
<v-row class="mb-4" v-if="currentPath">
<v-row class="mb-4" v-if="currentPath && !props.selectionMode">
<v-col>
<v-breadcrumbs :items="breadcrumbItems" class="pa-0">
<template v-slot:item="{ item }">
<v-breadcrumbs-item
:to="item.to"
@click="navigateToFolder(item.path)"
@click="navigateToFolder((item as any).path)"
class="cursor-pointer"
>
{{ item.title }}
</v-breadcrumbs-item>
@ -41,7 +53,7 @@
@update:model-value="filterFiles"
/>
</v-col>
<v-col cols="12" md="6" class="d-flex justify-end ga-2">
<v-col cols="12" md="6" class="d-flex justify-end ga-2" v-if="!props.selectionMode">
<v-btn
color="secondary"
size="large"
@ -63,7 +75,7 @@
</v-row>
<!-- Bulk Actions Bar (shown when items selected) -->
<v-row v-if="selectedItems.length > 0" class="mb-4">
<v-row v-if="selectedItems.length > 0 && !props.selectionMode" class="mb-4">
<v-col>
<v-alert
type="info"
@ -312,7 +324,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import FileUploader from '~/components/FileUploader.vue';
import FilePreviewModal from '~/components/FilePreviewModal.vue';
@ -325,8 +337,20 @@ interface FileItem {
icon: string;
displayName: string;
isFolder: boolean;
path?: string;
}
interface Props {
selectionMode?: boolean;
}
interface Emits {
(e: 'file-selected', file: FileItem): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const toast = useToast();
// Data
@ -383,22 +407,42 @@ const breadcrumbItems = computed(() => {
return items;
});
// Load files
const loadFiles = async () => {
// Load files with retry logic
const loadFiles = async (retryCount = 0) => {
loading.value = true;
try {
const response = await $fetch('/api/files/list', {
params: {
prefix: currentPath.value,
recursive: false,
}
},
timeout: 15000 // 15 second timeout
});
files.value = response.files;
filteredFiles.value = response.files;
} catch (error) {
toast.error('Failed to load files');
files.value = response.files || [];
filteredFiles.value = response.files || [];
} catch (error: any) {
console.error(`Failed to load files (attempt ${retryCount + 1}/3):`, error);
// Retry on certain errors
if (retryCount < 2 && (
error.message?.includes('Failed to fetch') ||
error.message?.includes('Network') ||
error.statusCode === 500 ||
error.statusCode === 503
)) {
console.log('Retrying file load...');
setTimeout(() => {
loadFiles(retryCount + 1);
}, (retryCount + 1) * 1000); // Exponential backoff
} else {
toast.error('Failed to load files. Please refresh the page.');
files.value = [];
filteredFiles.value = [];
}
} finally {
loading.value = false;
if (retryCount === 0 || retryCount === 2) {
loading.value = false;
}
}
};
@ -417,6 +461,15 @@ const filterFiles = () => {
// Handle file/folder click
const handleFileClick = (item: FileItem) => {
if (props.selectionMode && !item.isFolder) {
// In selection mode, emit the file for attachment
emit('file-selected', {
...item,
path: item.name
});
return;
}
if (item.isFolder) {
navigateToFolder(item.name);
} else if (canPreview(item)) {

View File

@ -388,40 +388,81 @@ async function fetchImapEmails(
// Group emails into threads based on subject and references
function groupIntoThreads(emails: EmailMessage[]): any[] {
const threads = new Map<string, EmailMessage[]>();
const emailById = new Map<string, EmailMessage>();
// First pass: index all emails by ID
emails.forEach(email => {
// Normalize subject by removing Re:, Fwd:, etc.
emailById.set(email.id, email);
});
// Second pass: group emails into threads
emails.forEach(email => {
// Normalize subject by removing Re:, Fwd:, etc. and extra whitespace
const normalizedSubject = email.subject
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
.trim();
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
// Find existing thread or create new one
// Check if this email belongs to an existing thread
let threadFound = false;
for (const [threadId, threadEmails] of threads.entries()) {
const threadSubject = threadEmails[0].subject
.replace(/^(Re:|Fwd:|Fw:)\s*/gi, '')
.trim();
if (threadSubject === normalizedSubject) {
threadEmails.push(email);
threadFound = true;
break;
// First, check if it has a threadId (in-reply-to header)
if (email.threadId) {
// Look for the parent email
const parentEmail = emailById.get(email.threadId);
if (parentEmail) {
// Find which thread the parent belongs to
for (const [threadId, threadEmails] of threads.entries()) {
if (threadEmails.some(e => e.id === parentEmail.id)) {
threadEmails.push(email);
threadFound = true;
break;
}
}
}
}
// If not found by threadId, try to match by subject
if (!threadFound) {
for (const [threadId, threadEmails] of threads.entries()) {
const threadSubject = threadEmails[0].subject
.replace(/^(Re:|Fwd:|Fw:|RE:|FW:|FWD:)\s*/gi, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
// Check if subjects match (case-insensitive)
if (threadSubject === normalizedSubject) {
threadEmails.push(email);
threadFound = true;
break;
}
}
}
// If still not found, create a new thread
if (!threadFound) {
threads.set(email.id, [email]);
}
});
// Convert to array format and sort threads by latest timestamp (newest first)
// Convert to array format and sort emails within each thread
return Array.from(threads.entries())
.map(([threadId, emails]) => ({
id: threadId,
subject: emails[0].subject,
emailCount: emails.length,
latestTimestamp: emails[0].timestamp, // First email is newest since we sorted desc
emails: emails
}))
.map(([threadId, threadEmails]) => {
// Sort emails within thread by timestamp (oldest first for chronological order)
threadEmails.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return {
id: threadId,
subject: threadEmails[0].subject,
emailCount: threadEmails.length,
latestTimestamp: threadEmails[threadEmails.length - 1].timestamp, // Latest email
emails: threadEmails
};
})
// Sort threads by latest activity (newest first)
.sort((a, b) => new Date(b.latestTimestamp).getTime() - new Date(a.latestTimestamp).getTime());
}

View File

@ -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;
}

View File

@ -66,17 +66,28 @@ export default defineEventHandler(async (event) => {
}
};
const testImapConnection = () => {
const testImapConnection = (retryCount = 0): Promise<boolean> => {
return new Promise((resolve, reject) => {
console.log('[test-connection] Testing IMAP connection...');
console.log(`[test-connection] Testing IMAP connection... (Attempt ${retryCount + 1}/3)`);
const imap = new Imap(imapConfig);
// Add a timeout to prevent hanging
const timeout = setTimeout(() => {
console.error('[test-connection] IMAP connection timeout');
imap.end();
reject(new Error('IMAP connection timeout after 10 seconds'));
}, 10000); // 10 second timeout
// Retry on timeout if we haven't exceeded max retries
if (retryCount < 2) {
console.log('[test-connection] Retrying IMAP connection after timeout...');
setTimeout(() => {
testImapConnection(retryCount + 1)
.then(resolve)
.catch(reject);
}, (retryCount + 1) * 1000); // Exponential backoff
} else {
reject(new Error('IMAP connection timeout after 15 seconds and 3 attempts'));
}
}, 15000); // 15 second timeout per attempt
imap.once('ready', () => {
console.log('[test-connection] IMAP connection successful');
@ -88,7 +99,25 @@ export default defineEventHandler(async (event) => {
imap.once('error', (err: Error) => {
console.error('[test-connection] IMAP connection error:', err);
clearTimeout(timeout);
reject(err);
// Retry on certain errors if we haven't exceeded max retries
const shouldRetry = retryCount < 2 && (
err.message.includes('ECONNRESET') ||
err.message.includes('ETIMEDOUT') ||
err.message.includes('ENOTFOUND') ||
err.message.includes('socket hang up')
);
if (shouldRetry) {
console.log(`[test-connection] Retrying IMAP connection after error: ${err.message}`);
setTimeout(() => {
testImapConnection(retryCount + 1)
.then(resolve)
.catch(reject);
}, (retryCount + 1) * 1000); // Exponential backoff
} else {
reject(err);
}
});
imap.connect();
@ -98,7 +127,7 @@ export default defineEventHandler(async (event) => {
try {
await testImapConnection();
} catch (imapError: any) {
console.error('[test-connection] IMAP connection failed:', imapError);
console.error('[test-connection] IMAP connection failed after all retries:', imapError);
throw new Error(`IMAP connection failed: ${imapError.message || imapError}`);
}

View File

@ -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',
});
}
});

View File

@ -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);
}

View File

@ -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);
}

View File

@ -12,6 +12,14 @@ export default defineEventHandler(async (event) => {
});
}
// Protect EOIs folder from deletion
if (fileName === 'EOIs/' || fileName === 'EOIs') {
throw createError({
statusCode: 403,
statusMessage: 'The EOIs folder is protected and cannot be deleted',
});
}
// Delete folder or file based on type
if (isFolder) {
await deleteFolder(fileName);

View File

@ -12,16 +12,44 @@ export default defineEventHandler(async (event) => {
});
}
// Get the download URL from MinIO
const url = await getDownloadUrl(fileName);
// Retry logic for getting download URL and fetching file
let response: Response | null = null;
let lastError: any = null;
// Fetch the file from MinIO
const response = await fetch(url);
for (let attempt = 0; attempt < 3; attempt++) {
try {
console.log(`[proxy-download] Attempting to download ${fileName} (attempt ${attempt + 1}/3)`);
// Get the download URL from MinIO
const url = await getDownloadUrl(fileName);
// Fetch the file from MinIO with timeout
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout
response = await fetch(url, { signal: controller.signal });
clearTimeout(timeout);
if (response.ok) {
break; // Success, exit retry loop
}
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
} catch (error: any) {
lastError = error;
console.error(`[proxy-download] Attempt ${attempt + 1} failed:`, error.message);
// Wait before retry with exponential backoff
if (attempt < 2) {
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
}
}
}
if (!response.ok) {
if (!response || !response.ok) {
throw createError({
statusCode: response.status,
statusMessage: 'Failed to fetch file from storage',
statusCode: response?.status || 500,
statusMessage: lastError?.message || 'Failed to fetch file from storage after 3 attempts',
});
}

View File

@ -8,7 +8,15 @@ export default defineEventHandler(async (event) => {
if (!oldName || !newName) {
throw createError({
statusCode: 400,
statusMessage: 'Old name and new name are required',
statusMessage: 'Both old and new names are required',
});
}
// Protect EOIs folder from renaming
if (oldName === 'EOIs/' || oldName === 'EOIs') {
throw createError({
statusCode: 403,
statusMessage: 'The EOIs folder is protected and cannot be renamed',
});
}

View File

@ -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);
});

View File

@ -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();
}

View File

@ -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();
}

153
server/utils/documeso.ts Normal file
View File

@ -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;
};

View File

@ -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'));
}
});
});
}

45
server/utils/email.ts Normal file
View File

@ -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;
}
}

View File

@ -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;
}
};

View File

@ -299,3 +299,34 @@ export const triggerWebhook = async (url: string, payload: any) =>
method: "POST",
body: payload,
});
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
// Get existing EOI Document array or create new one
const interest = await getInterestById(id);
const existingDocuments = interest['EOI Document'] || [];
// Add the new document to the array
const updatedDocuments = [...existingDocuments, documentData];
// Update the interest with the new EOI Document array
return updateInterest(id, {
'EOI Document': updatedDocuments
});
};
export const getInterestByFieldAsync = async (fieldName: string, value: any): Promise<Interest | null> => {
try {
const response = await getInterests();
const interests = response.list || [];
// Find interest where the field matches the value
const interest = interests.find(i => (i as any)[fieldName] === value);
return interest || null;
} catch (error) {
console.error('Error fetching interest by field:', error);
return null;
}
};