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:
56
server/api/eoi/check-signature-status.ts
Normal file
56
server/api/eoi/check-signature-status.ts
Normal 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',
|
||||
});
|
||||
}
|
||||
});
|
||||
323
server/api/eoi/send-reminders.ts
Normal file
323
server/api/eoi/send-reminders.ts
Normal 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);
|
||||
}
|
||||
141
server/api/eoi/upload-document.ts
Normal file
141
server/api/eoi/upload-document.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user