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:
parent
618c888b85
commit
f7cc2973e1
|
|
@ -13,3 +13,7 @@ NUXT_EMAIL_IMAP_PORT=993
|
||||||
NUXT_EMAIL_SMTP_HOST=mail.portnimara.com
|
NUXT_EMAIL_SMTP_HOST=mail.portnimara.com
|
||||||
NUXT_EMAIL_SMTP_PORT=587
|
NUXT_EMAIL_SMTP_PORT=587
|
||||||
NUXT_EMAIL_LOGO_URL=https://portnimara.com/logo.png
|
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
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@
|
||||||
<EmailComposer
|
<EmailComposer
|
||||||
:interest="interest"
|
:interest="interest"
|
||||||
@sent="onEmailSent"
|
@sent="onEmailSent"
|
||||||
|
@eoiGenerated="onEoiGenerated"
|
||||||
class="mb-4"
|
class="mb-4"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -56,7 +57,12 @@ interface Props {
|
||||||
interest: Interest;
|
interest: Interest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: 'interestUpdated'): void;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
const isConnected = ref(false);
|
const isConnected = ref(false);
|
||||||
const connectedEmail = ref('');
|
const connectedEmail = ref('');
|
||||||
|
|
@ -83,6 +89,11 @@ const onEmailSent = (messageId: string) => {
|
||||||
threadView.value?.reloadEmails();
|
threadView.value?.reloadEmails();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onEoiGenerated = () => {
|
||||||
|
// Emit event to parent to refresh interest data
|
||||||
|
emit('interestUpdated');
|
||||||
|
};
|
||||||
|
|
||||||
const disconnect = () => {
|
const disconnect = () => {
|
||||||
// Clear session storage
|
// Clear session storage
|
||||||
sessionStorage.removeItem('emailSessionId');
|
sessionStorage.removeItem('emailSessionId');
|
||||||
|
|
|
||||||
|
|
@ -42,16 +42,18 @@
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@click="insertEOILink"
|
@click="insertEOILink"
|
||||||
:disabled="sending"
|
:disabled="sending || generatingEOI"
|
||||||
|
:loading="generatingEOI"
|
||||||
>
|
>
|
||||||
<v-icon start>mdi-link</v-icon>
|
<v-icon start>mdi-link</v-icon>
|
||||||
Insert EOI Link
|
{{ generatingEOI ? 'Generating...' : 'Insert EOI Link' }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
@click="insertFormLink"
|
@click="insertFormLink"
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
|
v-show="false"
|
||||||
>
|
>
|
||||||
<v-icon start>mdi-form-select</v-icon>
|
<v-icon start>mdi-form-select</v-icon>
|
||||||
Insert Form Link
|
Insert Form Link
|
||||||
|
|
@ -156,6 +158,7 @@ interface Props {
|
||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'sent', messageId: string): void;
|
(e: 'sent', messageId: string): void;
|
||||||
|
(e: 'eoiGenerated'): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|
@ -166,6 +169,7 @@ const toast = useToast();
|
||||||
|
|
||||||
const form = ref();
|
const form = ref();
|
||||||
const sending = ref(false);
|
const sending = ref(false);
|
||||||
|
const generatingEOI = ref(false);
|
||||||
const showSignatureSettings = ref(false);
|
const showSignatureSettings = ref(false);
|
||||||
const includeSignature = ref(true);
|
const includeSignature = ref(true);
|
||||||
|
|
||||||
|
|
@ -192,11 +196,40 @@ const getSessionId = () => {
|
||||||
return sessionStorage.getItem('emailSessionId') || '';
|
return sessionStorage.getItem('emailSessionId') || '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const insertEOILink = () => {
|
const insertEOILink = async () => {
|
||||||
// Generate EOI link similar to the existing EOI Send to Sales functionality
|
// Check if we're already generating
|
||||||
const eoiLink = `https://portnimara.com/eoi/${props.interest.Id}`;
|
if (generatingEOI.value) return;
|
||||||
email.value.body += `\n\nPlease click here to complete your Expression of Interest: ${eoiLink}\n`;
|
|
||||||
toast.success('EOI link inserted');
|
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 = () => {
|
const insertFormLink = () => {
|
||||||
|
|
@ -221,14 +254,12 @@ const getSignaturePreview = () => {
|
||||||
return `
|
return `
|
||||||
<div style="margin-top: 20px;">
|
<div style="margin-top: 20px;">
|
||||||
<div style="font-weight: bold;">${sig.name || 'Your Name'}</div>
|
<div style="font-weight: bold;">${sig.name || 'Your Name'}</div>
|
||||||
<div style="color: #666;">${sig.title || 'Your Title'}</div>
|
<div style="color: #666; margin-bottom: 8px;">${sig.title || 'Your Title'}</div>
|
||||||
<br>
|
<div style="font-weight: bold; margin-bottom: 12px;">${sig.company || 'Company Name'}</div>
|
||||||
<div style="font-weight: bold;">${sig.company || 'Company Name'}</div>
|
|
||||||
<br>
|
|
||||||
${contactLines ? contactLines + '<br>' : ''}
|
${contactLines ? contactLines + '<br>' : ''}
|
||||||
<a href="mailto:${userEmail}" style="color: #0066cc;">${userEmail}</a>
|
<a href="mailto:${userEmail}" style="color: #0066cc;">${userEmail}</a>
|
||||||
<br><br>
|
<br><br>
|
||||||
<img src="${process.env.NUXT_EMAIL_LOGO_URL || 'https://portnimara.com/logo.png'}" alt="Logo" style="height: 80px;">
|
<img src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" alt="Port Nimara" style="height: 60px; max-width: 200px;">
|
||||||
<br>
|
<br>
|
||||||
<div style="color: #666; font-size: 12px; margin-top: 10px;">
|
<div style="color: #666; font-size: 12px; margin-top: 10px;">
|
||||||
The information in this message is confidential and may be privileged.<br>
|
The information in this message is confidential and may be privileged.<br>
|
||||||
|
|
|
||||||
|
|
@ -626,10 +626,86 @@
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<!-- EOI Links Section (only shows if EOI has been sent) -->
|
||||||
|
<v-card
|
||||||
|
v-if="hasEOILinks"
|
||||||
|
variant="flat"
|
||||||
|
class="mb-6"
|
||||||
|
>
|
||||||
|
<v-card-title class="text-h6 d-flex align-center pb-4">
|
||||||
|
<v-icon class="mr-2" color="primary">mdi-link-variant</v-icon>
|
||||||
|
EOI Links
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="pt-2">
|
||||||
|
<v-list>
|
||||||
|
<v-list-item
|
||||||
|
v-if="(interest as any)['EOI Client Link']"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar color="primary" size="40">
|
||||||
|
<v-icon>mdi-account</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Client ({{ interest['Full Name'] }})</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-truncate">{{ (interest as any)['EOI Client Link'] }}</v-list-item-subtitle>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-content-copy"
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard((interest as any)['EOI Client Link'], 'Client')"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="(interest as any)['EOI Oscar Link']"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar color="success" size="40">
|
||||||
|
<v-icon>mdi-account-check</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>Oscar Faragher (Approver)</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-truncate">{{ (interest as any)['EOI Oscar Link'] }}</v-list-item-subtitle>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-content-copy"
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard((interest as any)['EOI Oscar Link'], 'Oscar Faragher')"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
|
||||||
|
<v-list-item
|
||||||
|
v-if="(interest as any)['EOI David Link']"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-avatar color="secondary" size="40">
|
||||||
|
<v-icon>mdi-account-tie</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>David Mizrahi (Signer)</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="text-truncate">{{ (interest as any)['EOI David Link'] }}</v-list-item-subtitle>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-content-copy"
|
||||||
|
variant="text"
|
||||||
|
@click="copyToClipboard((interest as any)['EOI David Link'], 'David Mizrahi')"
|
||||||
|
></v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
<!-- Email Communication Section -->
|
<!-- Email Communication Section -->
|
||||||
<EmailCommunication
|
<EmailCommunication
|
||||||
v-if="interest"
|
v-if="interest"
|
||||||
:interest="interest"
|
:interest="interest"
|
||||||
|
@interestUpdated="onInterestUpdated"
|
||||||
/>
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
@ -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 = () => {
|
const closeModal = () => {
|
||||||
isOpen.value = false;
|
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<Interest>(`/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
|
// Load berths when component mounts
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAvailableBerths();
|
loadAvailableBerths();
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { clientEmail, interestId, sessionId, limit = 50 } = body;
|
const { clientEmail, interestId, sessionId, limit = 20 } = body;
|
||||||
|
|
||||||
if (!clientEmail || !sessionId) {
|
if (!clientEmail || !sessionId) {
|
||||||
throw createError({
|
throw createError({
|
||||||
|
|
@ -50,14 +50,20 @@ export default defineEventHandler(async (event) => {
|
||||||
if (interestId) {
|
if (interestId) {
|
||||||
try {
|
try {
|
||||||
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
|
const files = await listFiles(`client-emails/interest-${interestId}/`, true) as any[];
|
||||||
|
console.log('Found cached email files:', files.length);
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (file.name.endsWith('.json') && !file.isFolder) {
|
if (file.name.endsWith('.json') && !file.isFolder) {
|
||||||
try {
|
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();
|
const emailData = await response.json();
|
||||||
cachedEmails.push(emailData);
|
cachedEmails.push(emailData);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to read cached email:', err);
|
console.error('Failed to read cached email:', file.name, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,122 +81,27 @@ export default defineEventHandler(async (event) => {
|
||||||
tls: true,
|
tls: true,
|
||||||
tlsOptions: {
|
tlsOptions: {
|
||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
}
|
},
|
||||||
|
connTimeout: 10000, // 10 seconds connection timeout
|
||||||
|
authTimeout: 5000 // 5 seconds auth timeout
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch emails from IMAP
|
// Fetch emails from IMAP with timeout
|
||||||
const imapEmails: EmailMessage[] = await new Promise((resolve, reject) => {
|
let imapEmails: EmailMessage[] = [];
|
||||||
const emails: EmailMessage[] = [];
|
const timeoutPromise = new Promise<EmailMessage[]>((_, reject) =>
|
||||||
const imap = new Imap(imapConfig);
|
setTimeout(() => reject(new Error('IMAP connection timeout')), 15000)
|
||||||
|
);
|
||||||
|
|
||||||
imap.once('ready', () => {
|
try {
|
||||||
// Search for emails to/from the client
|
imapEmails = await Promise.race([
|
||||||
imap.openBox('INBOX', true, (err, box) => {
|
fetchImapEmails(imapConfig, userEmail, clientEmail, limit),
|
||||||
if (err) {
|
timeoutPromise
|
||||||
reject(err);
|
]);
|
||||||
return;
|
} catch (imapError) {
|
||||||
|
console.error('IMAP fetch failed:', imapError);
|
||||||
|
// Continue with cached emails only
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Combine cached and IMAP emails, remove duplicates
|
// Combine cached and IMAP emails, remove duplicates
|
||||||
const allEmails = [...cachedEmails, ...imapEmails];
|
const allEmails = [...cachedEmails, ...imapEmails];
|
||||||
const uniqueEmails = Array.from(
|
const uniqueEmails = Array.from(
|
||||||
|
|
@ -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
|
// Group emails into threads based on subject and references
|
||||||
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
function groupIntoThreads(emails: EmailMessage[]): any[] {
|
||||||
const threads = new Map<string, EmailMessage[]>();
|
const threads = new Map<string, EmailMessage[]>();
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -47,16 +47,15 @@ export default defineEventHandler(async (event) => {
|
||||||
const sig = signatureConfig || {};
|
const sig = signatureConfig || {};
|
||||||
const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('<br>') : '';
|
const contactLines = sig.contactInfo ? sig.contactInfo.split('\n').filter((line: string) => line.trim()).join('<br>') : '';
|
||||||
const signature = includeSignature ? `
|
const signature = includeSignature ? `
|
||||||
|
<br><br>
|
||||||
<div style="margin-top: 20px; font-family: Arial, sans-serif;">
|
<div style="margin-top: 20px; font-family: Arial, sans-serif;">
|
||||||
<div style="font-weight: bold;">${sig.name || defaultName}</div>
|
<div style="font-weight: bold;">${sig.name || defaultName}</div>
|
||||||
<div style="color: #666;">${sig.title || 'Sales & Marketing Director'}</div>
|
<div style="color: #666; margin-bottom: 8px;">${sig.title || 'Sales & Marketing Director'}</div>
|
||||||
<br>
|
<div style="font-weight: bold; margin-bottom: 12px;">${sig.company || 'Port Nimara'}</div>
|
||||||
<div style="font-weight: bold;">${sig.company || 'Port Nimara'}</div>
|
|
||||||
<br>
|
|
||||||
${contactLines ? contactLines + '<br>' : ''}
|
${contactLines ? contactLines + '<br>' : ''}
|
||||||
<a href="mailto:${sig.email || email}" style="color: #0066cc;">${sig.email || email}</a>
|
<a href="mailto:${sig.email || email}" style="color: #0066cc;">${sig.email || email}</a>
|
||||||
<br><br>
|
<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>
|
<br>
|
||||||
<div style="color: #666; font-size: 12px; margin-top: 10px;">
|
<div style="color: #666; font-size: 12px; margin-top: 10px;">
|
||||||
The information in this message is confidential and may be privileged.<br>
|
The information in this message is confidential and may be privileged.<br>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue