diff --git a/.env.example b/.env.example index 66f79dc..5681d46 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,7 @@ NUXT_EMAIL_IMAP_PORT=993 NUXT_EMAIL_SMTP_HOST=mail.portnimara.com NUXT_EMAIL_SMTP_PORT=587 NUXT_EMAIL_LOGO_URL=https://portnimara.com/logo.png + +# Documenso Configuration +NUXT_DOCUMENSO_API_KEY=your-documenso-api-key +NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev diff --git a/components/EmailCommunication.vue b/components/EmailCommunication.vue index b45396a..0b2db2f 100644 --- a/components/EmailCommunication.vue +++ b/components/EmailCommunication.vue @@ -32,6 +32,7 @@ @@ -56,7 +57,12 @@ interface Props { interest: Interest; } +interface Emits { + (e: 'interestUpdated'): void; +} + const props = defineProps(); +const emit = defineEmits(); const isConnected = ref(false); const connectedEmail = ref(''); @@ -83,6 +89,11 @@ const onEmailSent = (messageId: string) => { threadView.value?.reloadEmails(); }; +const onEoiGenerated = () => { + // Emit event to parent to refresh interest data + emit('interestUpdated'); +}; + const disconnect = () => { // Clear session storage sessionStorage.removeItem('emailSessionId'); diff --git a/components/EmailComposer.vue b/components/EmailComposer.vue index e58fb62..d6c6b15 100644 --- a/components/EmailComposer.vue +++ b/components/EmailComposer.vue @@ -42,16 +42,18 @@ variant="outlined" size="small" @click="insertEOILink" - :disabled="sending" + :disabled="sending || generatingEOI" + :loading="generatingEOI" > mdi-link - Insert EOI Link + {{ generatingEOI ? 'Generating...' : 'Insert EOI Link' }} mdi-form-select Insert Form Link @@ -156,6 +158,7 @@ interface Props { interface Emits { (e: 'sent', messageId: string): void; + (e: 'eoiGenerated'): void; } const props = defineProps(); @@ -166,6 +169,7 @@ const toast = useToast(); const form = ref(); const sending = ref(false); +const generatingEOI = ref(false); const showSignatureSettings = ref(false); const includeSignature = ref(true); @@ -192,11 +196,40 @@ const getSessionId = () => { return sessionStorage.getItem('emailSessionId') || ''; }; -const insertEOILink = () => { - // Generate EOI link similar to the existing EOI Send to Sales functionality - const eoiLink = `https://portnimara.com/eoi/${props.interest.Id}`; - email.value.body += `\n\nPlease click here to complete your Expression of Interest: ${eoiLink}\n`; - toast.success('EOI link inserted'); +const insertEOILink = async () => { + // Check if we're already generating + if (generatingEOI.value) return; + + generatingEOI.value = true; + + try { + const response = await $fetch<{ + success: boolean; + clientSigningUrl: string; + documentId: string; + }>('/api/email/generate-eoi-document', { + method: 'POST', + headers: { + 'x-tag': user.value?.email ? '094ut234' : 'pjnvü1230', + }, + body: { + interestId: props.interest.Id + } + }); + + if (response.success && response.clientSigningUrl) { + email.value.body += `\n\nPlease click here to sign your Letter of Intent: ${response.clientSigningUrl}\n`; + toast.success('EOI generated and link inserted!'); + + // Emit event to refresh interest data + emit('eoiGenerated'); + } + } catch (error: any) { + console.error('Failed to generate EOI:', error); + toast.error(error.data?.statusMessage || 'Failed to generate EOI document'); + } finally { + generatingEOI.value = false; + } }; const insertFormLink = () => { @@ -221,14 +254,12 @@ const getSignaturePreview = () => { return `
${sig.name || 'Your Name'}
-
${sig.title || 'Your Title'}
-
-
${sig.company || 'Company Name'}
-
+
${sig.title || 'Your Title'}
+
${sig.company || 'Company Name'}
${contactLines ? contactLines + '
' : ''} ${userEmail}

- Logo + Port Nimara
The information in this message is confidential and may be privileged.
diff --git a/components/InterestDetailsModal.vue b/components/InterestDetailsModal.vue index 7c90c16..d491be8 100644 --- a/components/InterestDetailsModal.vue +++ b/components/InterestDetailsModal.vue @@ -626,10 +626,86 @@ + + + + mdi-link-variant + EOI Links + + + + + + Client ({{ interest['Full Name'] }}) + {{ (interest as any)['EOI Client Link'] }} + + + + + + Oscar Faragher (Approver) + {{ (interest as any)['EOI Oscar Link'] }} + + + + + + David Mizrahi (Signer) + {{ (interest as any)['EOI David Link'] }} + + + + + + @@ -723,6 +799,15 @@ const currentStep = computed(() => { ); }); +const hasEOILinks = computed(() => { + if (!interest.value) return false; + return !!( + (interest.value as any)['EOI Client Link'] || + (interest.value as any)['EOI Oscar Link'] || + (interest.value as any)['EOI David Link'] + ); +}); + const closeModal = () => { isOpen.value = false; }; @@ -1092,6 +1177,41 @@ const deleteInterest = async () => { } }; +// Copy to clipboard function +const copyToClipboard = async (text: string, recipient: string) => { + try { + await navigator.clipboard.writeText(text); + toast.success(`${recipient} link copied to clipboard!`); + } catch (err) { + console.error('Failed to copy text: ', err); + toast.error('Failed to copy link to clipboard'); + } +}; + +// Handle interest updated event from EmailCommunication +const onInterestUpdated = async () => { + // Reload the interest data + if (interest.value) { + try { + const updatedInterest = await $fetch(`/api/get-interest-by-id`, { + headers: { + "x-tag": user.value?.email ? "094ut234" : "pjnvü1230", + }, + params: { + id: interest.value.Id, + }, + }); + + if (updatedInterest) { + interest.value = { ...updatedInterest }; + emit("save", interest.value); // Trigger parent refresh + } + } catch (error) { + console.error('Failed to reload interest:', error); + } + } +}; + // Load berths when component mounts onMounted(() => { loadAvailableBerths(); diff --git a/server/api/email/fetch-thread.ts b/server/api/email/fetch-thread.ts index 9f763f0..9f914a9 100644 --- a/server/api/email/fetch-thread.ts +++ b/server/api/email/fetch-thread.ts @@ -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((_, 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 { + 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(); diff --git a/server/api/email/generate-eoi-document.ts b/server/api/email/generate-eoi-document.ts new file mode 100644 index 0000000..217951d --- /dev/null +++ b/server/api/email/generate-eoi-document.ts @@ -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 = {}; + 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", + }); + } + } +}); diff --git a/server/api/email/send.ts b/server/api/email/send.ts index 48485a9..6bc3965 100644 --- a/server/api/email/send.ts +++ b/server/api/email/send.ts @@ -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('
') : ''; const signature = includeSignature ? ` +

${sig.name || defaultName}
-
${sig.title || 'Sales & Marketing Director'}
-
-
${sig.company || 'Port Nimara'}
-
+
${sig.title || 'Sales & Marketing Director'}
+
${sig.company || 'Port Nimara'}
${contactLines ? contactLines + '
' : ''} ${sig.email || email}

- Port Nimara + Port Nimara
The information in this message is confidential and may be privileged.