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

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