Add Documenso integration for EOI document generation

- Add Documenso API configuration to environment variables
- Create endpoint to generate EOI documents with e-signature capability
- Update email composer to insert generated EOI document links
- Add UI indicators for EOI generation status in interest details
- Emit events to refresh interest data after EOI generation
This commit is contained in:
2025-06-09 22:40:37 +02:00
parent 618c888b85
commit f7cc2973e1
7 changed files with 611 additions and 132 deletions

View File

@@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
try {
const body = await readBody(event);
const { clientEmail, interestId, sessionId, limit = 50 } = body;
const { clientEmail, interestId, sessionId, limit = 20 } = body;
if (!clientEmail || !sessionId) {
throw createError({
@@ -50,14 +50,20 @@ export default defineEventHandler(async (event) => {
if (interestId) {
try {
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
console.log('Found cached email files:', files.length);
for (const file of files) {
if (file.name.endsWith('.json') && !file.isFolder) {
try {
const response = await fetch(`${process.env.NUXT_MINIO_ENDPOINT || 'http://localhost:9000'}/${useRuntimeConfig().minio.bucketName}/${file.name}`);
// Use the getDownloadUrl function to get a proper presigned URL
const { getDownloadUrl } = await import('~/server/utils/minio');
const downloadUrl = await getDownloadUrl(file.name);
const response = await fetch(downloadUrl);
const emailData = await response.json();
cachedEmails.push(emailData);
} catch (err) {
console.error('Failed to read cached email:', err);
console.error('Failed to read cached email:', file.name, err);
}
}
}
@@ -75,121 +81,26 @@ export default defineEventHandler(async (event) => {
tls: true,
tlsOptions: {
rejectUnauthorized: false
}
},
connTimeout: 10000, // 10 seconds connection timeout
authTimeout: 5000 // 5 seconds auth timeout
};
// Fetch emails from IMAP
const imapEmails: EmailMessage[] = await new Promise((resolve, reject) => {
const emails: EmailMessage[] = [];
const imap = new Imap(imapConfig);
// Fetch emails from IMAP with timeout
let imapEmails: EmailMessage[] = [];
const timeoutPromise = new Promise<EmailMessage[]>((_, reject) =>
setTimeout(() => reject(new Error('IMAP connection timeout')), 15000)
);
imap.once('ready', () => {
// Search for emails to/from the client
imap.openBox('INBOX', true, (err, box) => {
if (err) {
reject(err);
return;
}
const searchCriteria = [
'OR',
['FROM', clientEmail],
['TO', clientEmail]
];
imap.search(searchCriteria, (err, results) => {
if (err) {
reject(err);
return;
}
if (!results || results.length === 0) {
imap.end();
resolve(emails);
return;
}
// Limit results
const messagesToFetch = results.slice(-limit);
const fetch = imap.fetch(messagesToFetch, {
bodies: '',
struct: true,
envelope: true
});
fetch.on('message', (msg, seqno) => {
msg.on('body', (stream, info) => {
simpleParser(stream as any, async (err: any, parsed: any) => {
if (err) {
console.error('Parse error:', err);
return;
}
const email: EmailMessage = {
id: parsed.messageId || `${Date.now()}-${seqno}`,
from: parsed.from?.text || '',
to: Array.isArray(parsed.to)
? parsed.to.map((addr: any) => addr.text).join(', ')
: parsed.to?.text || '',
subject: parsed.subject || '',
body: parsed.text || '',
html: parsed.html || undefined,
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
direction: parsed.from?.text.includes(userEmail) ? 'sent' : 'received'
};
// Extract thread ID from headers if available
if (parsed.headers.has('in-reply-to')) {
email.threadId = parsed.headers.get('in-reply-to') as string;
}
emails.push(email);
});
});
});
fetch.once('error', (err) => {
console.error('Fetch error:', err);
reject(err);
});
fetch.once('end', () => {
imap.end();
});
});
});
// Also check Sent folder
imap.openBox('[Gmail]/Sent Mail', true, (err, box) => {
if (err) {
// Try common sent folder names
['Sent', 'Sent Items', 'Sent Messages'].forEach(folderName => {
imap.openBox(folderName, true, (err, box) => {
if (!err) {
// Search in sent folder
imap.search([['TO', clientEmail]], (err, results) => {
if (!err && results && results.length > 0) {
// Process sent emails similarly
}
});
}
});
});
}
});
});
imap.once('error', (err: any) => {
reject(err);
});
imap.once('end', () => {
resolve(emails);
});
imap.connect();
});
try {
imapEmails = await Promise.race([
fetchImapEmails(imapConfig, userEmail, clientEmail, limit),
timeoutPromise
]);
} catch (imapError) {
console.error('IMAP fetch failed:', imapError);
// Continue with cached emails only
}
// Combine cached and IMAP emails, remove duplicates
const allEmails = [...cachedEmails, ...imapEmails];
@@ -226,6 +137,136 @@ export default defineEventHandler(async (event) => {
}
});
// Separate function for IMAP fetching with proper cleanup
async function fetchImapEmails(
imapConfig: any,
userEmail: string,
clientEmail: string,
limit: number
): Promise<EmailMessage[]> {
return new Promise((resolve, reject) => {
const emails: EmailMessage[] = [];
const imap = new Imap(imapConfig);
let isResolved = false;
const cleanup = () => {
if (!isResolved) {
isResolved = true;
try {
imap.end();
} catch (e) {
console.error('Error closing IMAP connection:', e);
}
}
};
imap.once('ready', () => {
imap.openBox('INBOX', true, (err, box) => {
if (err) {
cleanup();
reject(err);
return;
}
const searchCriteria = [
['OR', ['FROM', clientEmail], ['TO', clientEmail]]
];
imap.search(searchCriteria, (err, results) => {
if (err) {
cleanup();
reject(err);
return;
}
if (!results || results.length === 0) {
cleanup();
resolve(emails);
return;
}
const messagesToFetch = results.slice(-limit);
let messagesProcessed = 0;
const fetch = imap.fetch(messagesToFetch, {
bodies: '',
struct: true,
envelope: true
});
fetch.on('message', (msg, seqno) => {
msg.on('body', (stream, info) => {
simpleParser(stream as any, async (err: any, parsed: any) => {
if (err) {
console.error('Parse error:', err);
messagesProcessed++;
if (messagesProcessed === messagesToFetch.length) {
cleanup();
resolve(emails);
}
return;
}
const email: EmailMessage = {
id: parsed.messageId || `${Date.now()}-${seqno}`,
from: parsed.from?.text || '',
to: Array.isArray(parsed.to)
? parsed.to.map((addr: any) => addr.text).join(', ')
: parsed.to?.text || '',
subject: parsed.subject || '',
body: parsed.text || '',
html: parsed.html || undefined,
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
direction: parsed.from?.text.includes(userEmail) ? 'sent' : 'received'
};
if (parsed.headers.has('in-reply-to')) {
email.threadId = parsed.headers.get('in-reply-to') as string;
}
emails.push(email);
messagesProcessed++;
if (messagesProcessed === messagesToFetch.length) {
cleanup();
resolve(emails);
}
});
});
});
fetch.once('error', (err) => {
console.error('Fetch error:', err);
cleanup();
reject(err);
});
fetch.once('end', () => {
if (messagesProcessed === 0) {
cleanup();
resolve(emails);
}
});
});
});
});
imap.once('error', (err: any) => {
cleanup();
reject(err);
});
imap.once('end', () => {
if (!isResolved) {
isResolved = true;
resolve(emails);
}
});
imap.connect();
});
}
// Group emails into threads based on subject and references
function groupIntoThreads(emails: EmailMessage[]): any[] {
const threads = new Map<string, EmailMessage[]>();

View File

@@ -0,0 +1,273 @@
import { getInterestById, updateInterest } from '~/server/utils/nocodb';
interface DocumensoRecipient {
id: number;
name: string;
email: string;
role: 'SIGNER' | 'APPROVER';
signingOrder: number;
signingUrl?: string;
}
interface DocumensoResponse {
id: string;
recipients: DocumensoRecipient[];
}
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 } = body;
if (!interestId) {
throw createError({ statusCode: 400, statusMessage: "Interest ID is required" });
}
// Get the interest data
const interest = await getInterestById(interestId);
if (!interest) {
throw createError({ statusCode: 404, statusMessage: "Interest not found" });
}
// Validate required fields
const requiredFields = [
{ field: 'Full Name', value: interest['Full Name'] },
{ field: 'Email Address', value: interest['Email Address'] },
{ field: 'Address', value: interest['Address'] },
{ field: 'Yacht Name', value: interest['Yacht Name'] },
{ field: 'Length', value: interest['Length'] },
{ field: 'Width', value: interest['Width'] },
{ field: 'Depth', value: interest['Depth'] }
];
const missingFields = requiredFields.filter(f => !f.value).map(f => f.field);
if (missingFields.length > 0) {
throw createError({
statusCode: 400,
statusMessage: `Missing required fields: ${missingFields.join(', ')}`
});
}
// Get linked berths
const berthsResponse = await $fetch<{ list: Array<{ 'Mooring Number': string }> }>(
"/api/get-interest-berths",
{
headers: {
"x-tag": xTagHeader,
},
params: {
interestId: interestId,
linkType: "berths",
},
}
);
const berths = berthsResponse.list || [];
if (berths.length === 0) {
throw createError({
statusCode: 400,
statusMessage: "No berths linked to this interest. Please link at least one berth."
});
}
// Concatenate berth numbers
const berthNumbers = berths.map(b => b['Mooring Number']).join(', ');
// Documenso API configuration
const documensoApiKey = process.env.NUXT_DOCUMENSO_API_KEY || 'api_malptg62zqyb0wrp';
const documensoBaseUrl = process.env.NUXT_DOCUMENSO_BASE_URL || 'https://signatures.portnimara.dev';
const templateId = '9';
// 1. Get template (optional - just to verify it exists)
try {
const templateResponse = await fetch(`${documensoBaseUrl}/api/v1/templates/${templateId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${documensoApiKey}`
}
});
if (!templateResponse.ok) {
throw new Error(`Template not found: ${templateResponse.statusText}`);
}
} catch (error) {
console.error('Failed to verify template:', error);
throw createError({
statusCode: 500,
statusMessage: "Failed to verify template. Please check Documenso configuration."
});
}
// 2. Create document
const createDocumentPayload = {
meta: {
message: `Dear ${interest['Full Name']},\n\nThank you for your interest in a berth at Port Nimara. Please click the link above to sign your LOI.\n\nBest Regards,\nPort Nimara Team`,
subject: "Your LOI is ready to be signed",
redirectUrl: "https://portnimara.com",
distributionMethod: "SEQUENTIAL"
},
title: `${interest['Full Name']}-EOI-NDA`,
externalId: `loi-${interestId}`,
formValues: {
"Name": interest['Full Name'],
"Draft": interest['Depth'],
"Email": interest['Email Address'],
"Width": interest['Width'],
"Length": interest['Length'],
"Address": interest['Address'],
"Lease_10": false,
"Purchase": true,
"Yacht Name": interest['Yacht Name'],
"Berth Number": berthNumbers
},
recipients: [
{
id: 155,
name: interest['Full Name'],
role: "SIGNER",
email: interest['Email Address'],
signingOrder: 1
},
{
id: 156,
name: "David Mizrahi",
role: "SIGNER",
email: "dm@portnimara.com",
signingOrder: 3
},
{
id: 157,
name: "Oscar Faragher",
role: "APPROVER",
email: "sales@portnimara.com",
signingOrder: 2
}
]
};
let documentResponse: DocumensoResponse;
try {
const response = await fetch(`${documensoBaseUrl}/api/v1/templates/${templateId}/generate-document`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${documensoApiKey}`
},
body: JSON.stringify(createDocumentPayload)
});
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to create document:', errorText);
throw new Error(`Failed to create document: ${response.statusText}`);
}
documentResponse = await response.json();
} catch (error) {
console.error('Document creation error:', error);
throw createError({
statusCode: 500,
statusMessage: "Failed to create EOI document. Please try again."
});
}
// 3. Setup completion emails
try {
const completionResponse = await fetch(`${documensoBaseUrl}/api/v1/documents/${documentResponse.id}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${documensoApiKey}`
},
body: JSON.stringify({
sendEmail: false,
sendCompletionEmails: true
})
});
if (!completionResponse.ok) {
console.error('Failed to setup completion emails:', await completionResponse.text());
// Don't fail the whole process if this fails
}
} catch (error) {
console.error('Completion email setup error:', error);
// Continue anyway
}
// Extract signing URLs from recipients
const signingLinks: Record<string, string> = {};
if (documentResponse.recipients) {
documentResponse.recipients.forEach(recipient => {
if (recipient.signingUrl) {
if (recipient.email === interest['Email Address']) {
signingLinks['Client'] = recipient.signingUrl;
} else if (recipient.email === 'dm@portnimara.com') {
signingLinks['David Mizrahi'] = recipient.signingUrl;
} else if (recipient.email === 'sales@portnimara.com') {
signingLinks['Oscar Faragher'] = recipient.signingUrl;
}
}
});
}
// 4. Update interest record
const currentDate = new Date();
const dateTimeString = currentDate.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: true
});
const extraComments = interest['Extra Comments'] || '';
const updatedComments = extraComments + (extraComments ? '\n' : '') + `EOI Sent ${dateTimeString}`;
const updateData: any = {
'EOI Status': 'Waiting for Signatures',
'Sales Process Level': 'LOI and NDA Sent',
'EOI Time Sent': currentDate.toISOString(),
'Extra Comments': updatedComments
};
// Add signing links to update data
if (signingLinks['Client']) {
updateData['EOI Client Link'] = signingLinks['Client'];
}
if (signingLinks['David Mizrahi']) {
updateData['EOI David Link'] = signingLinks['David Mizrahi'];
}
if (signingLinks['Oscar Faragher']) {
updateData['EOI Oscar Link'] = signingLinks['Oscar Faragher'];
}
await updateInterest(interestId, updateData);
return {
success: true,
documentId: documentResponse.id,
clientSigningUrl: signingLinks['Client'] || '',
signingLinks: signingLinks
};
} catch (error) {
console.error('Failed to generate EOI document:', error);
if (error instanceof Error) {
throw createError({
statusCode: 500,
statusMessage: error.message || "Failed to generate EOI document"
});
} else {
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
}
});

View File

@@ -47,16 +47,15 @@ export default defineEventHandler(async (event) => {
const sig = signatureConfig || {};
const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('<br>') : '';
const signature = includeSignature ? `
<br><br>
<div style="margin-top: 20px; font-family: Arial, sans-serif;">
<div style="font-weight: bold;">${sig.name || defaultName}</div>
<div style="color: #666;">${sig.title || 'Sales & Marketing Director'}</div>
<br>
<div style="font-weight: bold;">${sig.company || 'Port Nimara'}</div>
<br>
<div style="color: #666; margin-bottom: 8px;">${sig.title || 'Sales & Marketing Director'}</div>
<div style="font-weight: bold; margin-bottom: 12px;">${sig.company || 'Port Nimara'}</div>
${contactLines ? contactLines + '<br>' : ''}
<a href="mailto:${sig.email || email}" style="color: #0066cc;">${sig.email || email}</a>
<br><br>
<img src="${process.env.NUXT_EMAIL_LOGO_URL || 'https://portnimara.com/logo.png'}" alt="Port Nimara" style="height: 80px;">
<img src="${process.env.NUXT_EMAIL_LOGO_URL || 'https://portnimara.com/logo.png'}" alt="Port Nimara" style="height: 60px; max-width: 200px;">
<br>
<div style="color: #666; font-size: 12px; margin-top: 10px;">
The information in this message is confidential and may be privileged.<br>