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:
@@ -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());
|
||||
}
|
||||
|
||||
254
server/api/email/process-sales-eois.ts
Normal file
254
server/api/email/process-sales-eois.ts
Normal 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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -12,6 +12,14 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Protect EOIs folder from deletion
|
||||
if (fileName === 'EOIs/' || fileName === 'EOIs') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'The EOIs folder is protected and cannot be deleted',
|
||||
});
|
||||
}
|
||||
|
||||
// Delete folder or file based on type
|
||||
if (isFolder) {
|
||||
await deleteFolder(fileName);
|
||||
|
||||
@@ -12,16 +12,44 @@ export default defineEventHandler(async (event) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Get the download URL from MinIO
|
||||
const url = await getDownloadUrl(fileName);
|
||||
// Retry logic for getting download URL and fetching file
|
||||
let response: Response | null = null;
|
||||
let lastError: any = null;
|
||||
|
||||
// Fetch the file from MinIO
|
||||
const response = await fetch(url);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
console.log(`[proxy-download] Attempting to download ${fileName} (attempt ${attempt + 1}/3)`);
|
||||
|
||||
// Get the download URL from MinIO
|
||||
const url = await getDownloadUrl(fileName);
|
||||
|
||||
// Fetch the file from MinIO with timeout
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 30000); // 30 second timeout
|
||||
|
||||
response = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (response.ok) {
|
||||
break; // Success, exit retry loop
|
||||
}
|
||||
|
||||
lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
console.error(`[proxy-download] Attempt ${attempt + 1} failed:`, error.message);
|
||||
|
||||
// Wait before retry with exponential backoff
|
||||
if (attempt < 2) {
|
||||
await new Promise(resolve => setTimeout(resolve, (attempt + 1) * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response || !response.ok) {
|
||||
throw createError({
|
||||
statusCode: response.status,
|
||||
statusMessage: 'Failed to fetch file from storage',
|
||||
statusCode: response?.status || 500,
|
||||
statusMessage: lastError?.message || 'Failed to fetch file from storage after 3 attempts',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,15 @@ export default defineEventHandler(async (event) => {
|
||||
if (!oldName || !newName) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Old name and new name are required',
|
||||
statusMessage: 'Both old and new names are required',
|
||||
});
|
||||
}
|
||||
|
||||
// Protect EOIs folder from renaming
|
||||
if (oldName === 'EOIs/' || oldName === 'EOIs') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'The EOIs folder is protected and cannot be renamed',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
19
server/plugins/eoi-reminders.ts
Normal file
19
server/plugins/eoi-reminders.ts
Normal 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);
|
||||
});
|
||||
107
server/tasks/eoi-reminders.ts
Normal file
107
server/tasks/eoi-reminders.ts
Normal 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();
|
||||
}
|
||||
59
server/tasks/process-sales-emails.ts
Normal file
59
server/tasks/process-sales-emails.ts
Normal 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
153
server/utils/documeso.ts
Normal 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;
|
||||
};
|
||||
81
server/utils/email-utils.ts
Normal file
81
server/utils/email-utils.ts
Normal 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
45
server/utils/email.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -297,3 +297,21 @@ export const renameFolder = async (oldPath: string, newPath: string) => {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Create bucket if it doesn't exist
|
||||
export const createBucketIfNotExists = async (bucketName?: string) => {
|
||||
const client = getMinioClient();
|
||||
const bucket = bucketName || useRuntimeConfig().minio.bucketName;
|
||||
|
||||
try {
|
||||
const exists = await client.bucketExists(bucket);
|
||||
if (!exists) {
|
||||
await client.makeBucket(bucket);
|
||||
console.log(`Bucket '${bucket}' created successfully`);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error creating bucket:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -299,3 +299,34 @@ export const triggerWebhook = async (url: string, payload: any) =>
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
|
||||
export const updateInterestEOIDocument = async (id: string, documentData: any) => {
|
||||
console.log('[nocodb.updateInterestEOIDocument] Updating EOI document for interest:', id);
|
||||
|
||||
// Get existing EOI Document array or create new one
|
||||
const interest = await getInterestById(id);
|
||||
const existingDocuments = interest['EOI Document'] || [];
|
||||
|
||||
// Add the new document to the array
|
||||
const updatedDocuments = [...existingDocuments, documentData];
|
||||
|
||||
// Update the interest with the new EOI Document array
|
||||
return updateInterest(id, {
|
||||
'EOI Document': updatedDocuments
|
||||
});
|
||||
};
|
||||
|
||||
export const getInterestByFieldAsync = async (fieldName: string, value: any): Promise<Interest | null> => {
|
||||
try {
|
||||
const response = await getInterests();
|
||||
const interests = response.list || [];
|
||||
|
||||
// Find interest where the field matches the value
|
||||
const interest = interests.find(i => (i as any)[fieldName] === value);
|
||||
|
||||
return interest || null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching interest by field:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user