approval from email feature added
This commit is contained in:
parent
d6e86ff7fd
commit
ec04538fa1
180
package-lock.json
generated
180
package-lock.json
generated
@ -25,6 +25,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"googleapis": "^171.4.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@ -7823,6 +7824,179 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis": {
|
||||
"version": "171.4.0",
|
||||
"resolved": "https://registry.npmjs.org/googleapis/-/googleapis-171.4.0.tgz",
|
||||
"integrity": "sha512-xybFL2SmmUgIifgsbsRQYRdNrSAYwxWZDmkZTGjUIaRnX5jPqR8el/cEvo6rCqh7iaZx6MfEPS/lrDgZ0bymkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"google-auth-library": "^10.2.0",
|
||||
"googleapis-common": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-8.0.1.tgz",
|
||||
"integrity": "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"gaxios": "^7.0.0-rc.4",
|
||||
"google-auth-library": "^10.1.0",
|
||||
"qs": "^6.7.0",
|
||||
"url-template": "^2.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/gaxios": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
|
||||
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/google-auth-library": {
|
||||
"version": "10.6.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis-common/node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis/node_modules/gaxios": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz",
|
||||
"integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^7.0.1",
|
||||
"node-fetch": "^3.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis/node_modules/gcp-metadata": {
|
||||
"version": "8.1.2",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz",
|
||||
"integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"gaxios": "^7.0.0",
|
||||
"google-logging-utils": "^1.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis/node_modules/google-auth-library": {
|
||||
"version": "10.6.2",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz",
|
||||
"integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"ecdsa-sig-formatter": "^1.0.11",
|
||||
"gaxios": "^7.1.4",
|
||||
"gcp-metadata": "8.1.2",
|
||||
"google-logging-utils": "1.1.3",
|
||||
"jws": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis/node_modules/google-logging-utils": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz",
|
||||
"integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/googleapis/node_modules/node-fetch": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
|
||||
"integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"data-uri-to-buffer": "^4.0.0",
|
||||
"fetch-blob": "^3.1.4",
|
||||
"formdata-polyfill": "^4.0.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/node-fetch"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@ -12844,6 +13018,12 @@
|
||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/url-template": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||
"integrity": "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==",
|
||||
"license": "BSD"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
||||
@ -48,6 +48,7 @@
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"fast-xml-parser": "^5.3.3",
|
||||
"googleapis": "^171.4.0",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
||||
14
src/config/gmail.config.ts
Normal file
14
src/config/gmail.config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
export const gmailConfig = {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID || '',
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
|
||||
redirectUri: process.env.GOOGLE_REDIRECT_URI || 'http://localhost:5000/api/v1/gmail/oauth/callback',
|
||||
pubsubTopic: process.env.GMAIL_PUBSUB_TOPIC || 'gmail-notifications',
|
||||
webhookBaseUrl: process.env.PUBLIC_BASE_URL || 'http://localhost:5000',
|
||||
tokenPath: process.env.GMAIL_TOKEN_PATH || './credentials/gmail-tokens.json',
|
||||
serviceAccountPath: process.env.GCP_KEY_FILE || './credentials/re-platform-workflow-dealer-3d5738fcc1f9.json',
|
||||
impersonateEmail: process.env.APPROVAL_MAILBOX || 'approvals@royalenfield.com',
|
||||
approvalMailbox: process.env.APPROVAL_MAILBOX || 'approvals@royalenfield.com',
|
||||
};
|
||||
53
src/controllers/gmailWebhook.controller.ts
Normal file
53
src/controllers/gmailWebhook.controller.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { gmailService } from '../services/gmail.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
export class GmailWebhookController {
|
||||
/**
|
||||
* Handle Pub/Sub Push Notification
|
||||
*/
|
||||
async handlePubSubPush(req: Request, res: Response) {
|
||||
try {
|
||||
// Pub/Sub messages are base64 encoded in the "message.data" field
|
||||
const message = req.body.message;
|
||||
if (!message || !message.data) {
|
||||
logger.warn(`[GmailWebhook] Invalid Pub/Sub message received`);
|
||||
return res.status(400).send('Invalid Pub/Sub message');
|
||||
}
|
||||
|
||||
const decodedData = JSON.parse(Buffer.from(message.data, 'base64').toString());
|
||||
logger.info(`[GmailWebhook] Received push notification:`, decodedData);
|
||||
|
||||
// Process the notification asynchronously
|
||||
// We return 200 immediately to Pub/Sub to acknowledge receipt
|
||||
gmailService.processNotification(decodedData).catch(err => {
|
||||
logger.error(`[GmailWebhook] Error in background processing:`, err);
|
||||
});
|
||||
|
||||
return res.status(200).send('OK');
|
||||
} catch (error) {
|
||||
logger.error(`[GmailWebhook] Failed to handle push:`, error);
|
||||
return res.status(500).send('Internal Server Error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual trigger for setup/watch
|
||||
*/
|
||||
async setupWatch(_req: Request, res: Response) {
|
||||
try {
|
||||
const data = await gmailService.setupWatch();
|
||||
return res.status(200).json({
|
||||
message: 'Watch setup successful',
|
||||
data
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(500).json({
|
||||
message: 'Failed to setup watch',
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gmailWebhookController = new GmailWebhookController();
|
||||
@ -3,11 +3,11 @@
|
||||
*/
|
||||
|
||||
import { ApprovalRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getRequestSummaryTable, getEmailActionButtons, getEmailHeader, HeaderStyles, getResponsiveStyles, wrapRichText, getEmailContainerStyles, getCustomMessageSection } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
return `
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@ -25,9 +25,9 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||
<!-- Header -->
|
||||
${getEmailHeader(getBrandedHeader({
|
||||
title: 'Approval Request',
|
||||
...HeaderStyles.info
|
||||
}))}
|
||||
title: 'Approval Request',
|
||||
...HeaderStyles.info
|
||||
}))}
|
||||
|
||||
<!-- Content -->
|
||||
<tr>
|
||||
@ -40,67 +40,15 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
<strong style="color: #333333;">${data.initiatorName}</strong> has submitted a request that requires your approval.
|
||||
</p>
|
||||
|
||||
<!-- Request Details Box -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 25px;">
|
||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
|
||||
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
||||
<strong>Request ID:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestId}
|
||||
</td>
|
||||
</tr>
|
||||
${data.requestTitle ? `
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Title:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTitle}
|
||||
</td>
|
||||
</tr>
|
||||
` : ''}
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Initiator:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.initiatorName}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Submitted On:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestDate}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Time:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestTime}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 8px 0; color: #666666; font-size: 14px;">
|
||||
<strong>Request Type:</strong>
|
||||
</td>
|
||||
<td style="padding: 8px 0; color: #333333; font-size: 14px;">
|
||||
${data.requestType}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Request Summary (FR-2.2.3) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Summary:</h3>
|
||||
${getRequestSummaryTable({
|
||||
priority: data.priority,
|
||||
requestType: data.requestType,
|
||||
purpose: data.requestDescription
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Custom Message Section -->
|
||||
${getCustomMessageSection(data.customMessage)}
|
||||
@ -116,6 +64,17 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||
<!-- Priority Section (dynamic) -->
|
||||
${getPrioritySection(data.priority)}
|
||||
|
||||
${data.showActionButtons ? `
|
||||
<!-- QUICK ACTIONS -->
|
||||
<div style="margin-bottom: 40px; padding: 25px; border: 2px dashed #dee2e6; border-radius: 8px; background-color: #ffffff;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 18px; font-weight: 700; text-align: center; text-transform: uppercase; letter-spacing: 1px;">Quick Actions</h3>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; text-align: center;">
|
||||
You can approve or reject this request directly from your email by clicking one of the buttons below.
|
||||
</p>
|
||||
${getEmailActionButtons(data.requestId, data.requestId)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- View Details Button -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
|
||||
@ -679,42 +679,126 @@ export function getPrioritySection(priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITIC
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate approval chain visualization
|
||||
* Generate approval chain as a professional table
|
||||
* Fields: Level, Approver, Status, Comments
|
||||
*/
|
||||
export function getApprovalChain(approvers: ApprovalChainItem[]): string {
|
||||
return approvers.map(approver => {
|
||||
let icon = '';
|
||||
let textColor = '#333333';
|
||||
let status = '';
|
||||
export function getApprovalChainTable(approvers: ApprovalChainItem[]): string {
|
||||
if (!approvers || approvers.length === 0) return '';
|
||||
|
||||
const rows = approvers.map(approver => {
|
||||
let statusLabel = '';
|
||||
let statusColor = '#333333';
|
||||
let bgColor = '#ffffff';
|
||||
|
||||
switch (approver.status) {
|
||||
case 'approved':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #28a745; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">✓</span>`;
|
||||
status = approver.date ? `Approved on ${approver.date}` : 'Approved';
|
||||
break;
|
||||
case 'current':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #667eea; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
|
||||
textColor = '#667eea';
|
||||
status = 'Pending (Your Turn)';
|
||||
statusLabel = 'Approved';
|
||||
statusColor = '#28a745';
|
||||
break;
|
||||
case 'pending':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #ffc107; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
|
||||
status = 'Pending';
|
||||
statusLabel = 'Pending';
|
||||
statusColor = '#ffc107';
|
||||
break;
|
||||
case 'current':
|
||||
statusLabel = 'Pending (Action Required)';
|
||||
statusColor = '#007bff';
|
||||
bgColor = '#f0f7ff';
|
||||
break;
|
||||
case 'awaiting':
|
||||
icon = `<span style="display: inline-block; width: 30px; height: 30px; background-color: #cccccc; color: #ffffff; text-align: center; line-height: 30px; border-radius: 50%; font-size: 14px; margin-right: 10px; font-weight: bold;">${approver.levelNumber}</span>`;
|
||||
textColor = '#999999';
|
||||
status = 'Awaiting';
|
||||
statusLabel = 'Awaiting';
|
||||
statusColor = '#6c757d';
|
||||
break;
|
||||
}
|
||||
|
||||
return `
|
||||
<div style="padding: 10px 0; border-bottom: 1px solid #e9ecef;">
|
||||
${icon}
|
||||
<strong style="color: ${textColor};">${approver.name}</strong> - ${status}
|
||||
</div>
|
||||
<tr style="background-color: ${bgColor}; border-bottom: 1px solid #dee2e6;">
|
||||
<td style="padding: 12px 15px; font-size: 14px; color: #333333;">Level ${approver.levelNumber}</td>
|
||||
<td style="padding: 12px 15px; font-size: 14px; color: #333333;"><strong>${approver.name}</strong></td>
|
||||
<td style="padding: 12px 15px; font-size: 14px; color: ${statusColor}; font-weight: 600;">${statusLabel}</td>
|
||||
<td style="padding: 12px 15px; font-size: 14px; color: #666666; font-style: italic;">${approver.comments || '---'}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #dee2e6;" cellpadding="0" cellspacing="0">
|
||||
<thead>
|
||||
<tr style="background-color: #f8f9fa;">
|
||||
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Level</th>
|
||||
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Name</th>
|
||||
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Status</th>
|
||||
<th style="padding: 12px 15px; text-align: left; font-size: 13px; font-weight: 600; color: #495057; border-bottom: 2px solid #dee2e6;">Comment</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate request summary table (FR-2.2.3)
|
||||
*/
|
||||
export function getRequestSummaryTable(data: {
|
||||
priority: string;
|
||||
requestType: string;
|
||||
purpose?: string;
|
||||
[key: string]: any;
|
||||
}): string {
|
||||
const fields = [
|
||||
{ label: 'Urgency', value: data.priority },
|
||||
{ label: 'Request Type', value: data.requestType },
|
||||
{ label: 'Purpose', value: data.purpose || 'Business Requirement' }
|
||||
];
|
||||
|
||||
const rows = fields.map(field => `
|
||||
<tr>
|
||||
<td style="padding: 10px 15px; background-color: #f8f9fa; border: 1px solid #dee2e6; font-weight: 600; width: 30%; font-size: 14px;">${field.label}</td>
|
||||
<td style="padding: 10px 15px; border: 1px solid #dee2e6; width: 70%; font-size: 14px;">${field.value}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
return `
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 15px 0; border: 1px solid #dee2e6;" cellpadding="0" cellspacing="0">
|
||||
<tbody>
|
||||
${rows}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate interactive approval buttons for Gmail-based workflow
|
||||
*/
|
||||
export function getEmailActionButtons(requestId: string, requestNumber: string): string {
|
||||
const approvalEmail = process.env.APPROVAL_MAILBOX || 'approvals@royalenfield.com';
|
||||
|
||||
// Gmail actions usually work better with clear mailto links if not using AMP
|
||||
const approveMailto = `mailto:${approvalEmail}?subject=RE: [${requestNumber}] APPROVE&body=I approve this request.%0A%0AComments: `;
|
||||
const rejectMailto = `mailto:${approvalEmail}?subject=RE: [${requestNumber}] REJECT&body=I reject this request.%0A%0AReason: `;
|
||||
|
||||
return `
|
||||
<div style="margin: 30px 0; text-align: center;">
|
||||
<table role="presentation" style="margin: 0 auto;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 0 10px;">
|
||||
<a href="${approveMailto}" style="display: inline-block; padding: 12px 30px; background-color: #28a745; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 15px;">
|
||||
Approve via Email
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding: 0 10px;">
|
||||
<a href="${rejectMailto}" style="display: inline-block; padding: 12px 30px; background-color: #dc3545; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 15px;">
|
||||
Reject via Email
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p style="margin-top: 15px; font-size: 13px; color: #666666;">
|
||||
(Clicking a button will open your email client to send a reply)
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
import { MultiApproverRequestData } from './types';
|
||||
import { getEmailFooter, getPrioritySection, getApprovalChain, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getEmailFooter, getPrioritySection, getApprovalChainTable, getRequestSummaryTable, getEmailActionButtons, getEmailHeader, HeaderStyles, wrapRichText, getResponsiveStyles, getEmailContainerStyles } from './helpers';
|
||||
import { getBrandedHeader } from './branding.config';
|
||||
|
||||
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
|
||||
@ -94,16 +94,20 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- Approval Chain -->
|
||||
<!-- Request Summary (FR-2.2.3) -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Summary:</h3>
|
||||
${getRequestSummaryTable({
|
||||
priority: data.priority,
|
||||
requestType: data.requestType,
|
||||
purpose: data.requestDescription
|
||||
})}
|
||||
</div>
|
||||
|
||||
<!-- Approval Chain Table -->
|
||||
<div style="margin-bottom: 30px;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approval Chain:</h3>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
${getApprovalChain(data.approversList)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
${getApprovalChainTable(data.approversList)}
|
||||
</div>
|
||||
|
||||
<!-- Description (supports rich text HTML including tables) -->
|
||||
@ -124,6 +128,17 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
||||
</p>
|
||||
</div>
|
||||
|
||||
${data.showActionButtons ? `
|
||||
<!-- QUICK ACTIONS -->
|
||||
<div style="margin-bottom: 40px; padding: 25px; border: 2px dashed #dee2e6; border-radius: 8px; background-color: #ffffff;">
|
||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 18px; font-weight: 700; text-align: center; text-transform: uppercase; letter-spacing: 1px;">Quick Actions</h3>
|
||||
<p style="margin: 0 0 20px; color: #666666; font-size: 14px; text-align: center;">
|
||||
You can approve or reject this request directly from your email by clicking one of the buttons below.
|
||||
</p>
|
||||
${getEmailActionButtons(data.requestId, data.requestId)}
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- View Details Button -->
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
|
||||
@ -48,12 +48,14 @@ export interface ApprovalRequestData extends BaseEmailData {
|
||||
requestDescription: string;
|
||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||
customMessage?: string;
|
||||
showActionButtons?: boolean;
|
||||
}
|
||||
|
||||
export interface MultiApproverRequestData extends ApprovalRequestData {
|
||||
approverLevel: number;
|
||||
totalApprovers: number;
|
||||
approversList: ApprovalChainItem[];
|
||||
showActionButtons?: boolean;
|
||||
}
|
||||
|
||||
export interface ApprovalChainItem {
|
||||
@ -61,6 +63,7 @@ export interface ApprovalChainItem {
|
||||
status: 'approved' | 'pending' | 'current' | 'awaiting';
|
||||
date?: string;
|
||||
levelNumber: number;
|
||||
comments?: string | null;
|
||||
}
|
||||
|
||||
export interface ApprovalConfirmationData extends BaseEmailData {
|
||||
|
||||
88
src/migrations/20260413-create-incoming-emails.ts
Normal file
88
src/migrations/20260413-create-incoming-emails.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { QueryInterface, DataTypes } from 'sequelize';
|
||||
|
||||
export async function up(queryInterface: QueryInterface) {
|
||||
await queryInterface.createTable('incoming_emails', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
message_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true
|
||||
},
|
||||
thread_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
from: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
to: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
html_body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
received_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false
|
||||
},
|
||||
processed: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
action_taken: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true
|
||||
},
|
||||
parsed_comments: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
request_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
references: {
|
||||
model: 'workflow_requests',
|
||||
key: 'request_id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'SET NULL'
|
||||
},
|
||||
error: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
});
|
||||
|
||||
await queryInterface.addIndex('incoming_emails', ['message_id']);
|
||||
await queryInterface.addIndex('incoming_emails', ['request_id']);
|
||||
await queryInterface.addIndex('incoming_emails', ['from']);
|
||||
}
|
||||
|
||||
export async function down(queryInterface: QueryInterface) {
|
||||
await queryInterface.dropTable('incoming_emails');
|
||||
}
|
||||
119
src/models/IncomingEmail.ts
Normal file
119
src/models/IncomingEmail.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { DataTypes, Model, Optional } from 'sequelize';
|
||||
import { sequelize } from '../config/database';
|
||||
|
||||
interface IncomingEmailAttributes {
|
||||
id: string;
|
||||
messageId: string;
|
||||
threadId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
htmlBody?: string;
|
||||
receivedAt: Date;
|
||||
processed: boolean;
|
||||
actionTaken?: string; // 'APPROVE', 'REJECT', 'NONE'
|
||||
parsedComments?: string | null;
|
||||
requestId?: string;
|
||||
error?: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
}
|
||||
|
||||
interface IncomingEmailCreationAttributes extends Optional<IncomingEmailAttributes, 'id' | 'htmlBody' | 'processed' | 'actionTaken' | 'requestId' | 'error' | 'createdAt' | 'updatedAt'> {}
|
||||
|
||||
class IncomingEmail extends Model<IncomingEmailAttributes, IncomingEmailCreationAttributes> implements IncomingEmailAttributes {
|
||||
public id!: string;
|
||||
public messageId!: string;
|
||||
public threadId!: string;
|
||||
public from!: string;
|
||||
public to!: string;
|
||||
public subject!: string;
|
||||
public body!: string;
|
||||
public htmlBody?: string;
|
||||
public receivedAt!: Date;
|
||||
public processed!: boolean;
|
||||
public actionTaken?: string;
|
||||
public requestId?: string;
|
||||
public error?: string;
|
||||
public readonly createdAt!: Date;
|
||||
public readonly updatedAt!: Date;
|
||||
}
|
||||
|
||||
IncomingEmail.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true,
|
||||
},
|
||||
messageId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
field: 'message_id'
|
||||
},
|
||||
threadId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
field: 'thread_id'
|
||||
},
|
||||
from: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
to: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
subject: {
|
||||
type: DataTypes.STRING(500),
|
||||
allowNull: false
|
||||
},
|
||||
body: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
htmlBody: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'html_body'
|
||||
},
|
||||
receivedAt: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
field: 'received_at'
|
||||
},
|
||||
processed: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false
|
||||
},
|
||||
actionTaken: {
|
||||
type: DataTypes.STRING(20),
|
||||
allowNull: true,
|
||||
field: 'action_taken'
|
||||
},
|
||||
parsedComments: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
field: 'parsed_comments'
|
||||
},
|
||||
requestId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
field: 'request_id'
|
||||
},
|
||||
error: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
}
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'incoming_emails',
|
||||
underscored: true,
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
export { IncomingEmail };
|
||||
17
src/routes/gmail.routes.ts
Normal file
17
src/routes/gmail.routes.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Router } from 'express';
|
||||
import { gmailWebhookController } from '../controllers/gmailWebhook.controller';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* Pub/Sub Push Webhook
|
||||
* Usually: POST /api/v1/gmail/webhooks/push
|
||||
*/
|
||||
router.post('/webhooks/push', gmailWebhookController.handlePubSubPush);
|
||||
|
||||
/**
|
||||
* Setup Watch (Admin only or secure it)
|
||||
*/
|
||||
router.post('/watch/setup', gmailWebhookController.setupWatch);
|
||||
|
||||
export default router;
|
||||
@ -35,6 +35,7 @@ import antivirusRoutes from './antivirus.routes';
|
||||
import dealerExternalRoutes from './dealerExternal.routes';
|
||||
import form16Routes from './form16.routes';
|
||||
import hsnSacCodeRoutes from './hsnSacCode.routes';
|
||||
import gmailRoutes from './gmail.routes';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -81,6 +82,7 @@ router.use('/ai', aiLimiter, aiRoutes); // 20 re
|
||||
|
||||
// ── External webhooks (burst-friendly) ──
|
||||
router.use('/webhooks/dms', webhookLimiter, dmsWebhookRoutes); // 100 req/15min
|
||||
router.use('/gmail', gmailRoutes); // Gmail Pub/Sub & OAuth routes
|
||||
|
||||
// ── Dealer claims (SAP/PWC rate limiting at individual route level in dealerClaim.routes.ts) ──
|
||||
router.use('/dealer-claims', generalApiLimiter, dealerClaimRoutes); // 200 req/15min (SAP routes have additional stricter limits)
|
||||
|
||||
@ -185,6 +185,7 @@ async function runMigrations(): Promise<void> {
|
||||
const m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
|
||||
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
|
||||
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
|
||||
const m71 = require('../migrations/20260413-create-incoming-emails');
|
||||
|
||||
const migrations = [
|
||||
{ name: '2025103000-create-users', module: m0 },
|
||||
@ -262,6 +263,7 @@ async function runMigrations(): Promise<void> {
|
||||
{ name: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
||||
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
||||
{ name: '20260413-create-incoming-emails', module: m71 },
|
||||
];
|
||||
|
||||
// Dynamically import sequelize after secrets are loaded
|
||||
|
||||
83
src/scripts/test-email-approval.ts
Normal file
83
src/scripts/test-email-approval.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { gmailService } from '../services/gmail.service';
|
||||
import { IncomingEmail } from '../models/IncomingEmail';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { User } from '../models/User';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
/**
|
||||
* MOCK TEST SCRIPT
|
||||
* This script simulates the receipt of an email from testuser10@royalenfield.com
|
||||
* to approve a specific request.
|
||||
*/
|
||||
async function mockEmailApproval() {
|
||||
console.log('🚀 Starting Mock Email Approval Test...');
|
||||
|
||||
try {
|
||||
// 1. Find a sample request (or use a specific request number)
|
||||
const sampleRequest = await WorkflowRequest.findOne({
|
||||
where: { templateType: 'CUSTOM' },
|
||||
order: [['createdAt', 'DESC']]
|
||||
});
|
||||
|
||||
if (!sampleRequest) {
|
||||
console.error('❌ No CUSTOM requests found in DB to test with.');
|
||||
return;
|
||||
}
|
||||
const requestNumber = sampleRequest.requestNumber;
|
||||
console.log(`📝 Testing with Request: ${requestNumber}`);
|
||||
|
||||
// 2. Find/Ensure testuser10 exists
|
||||
// Change this to the actual email of user 10 in your DB
|
||||
const testUserEmail = 'testuser10@royalenfield.com';
|
||||
const user = await User.findOne({ where: { email: testUserEmail } });
|
||||
if (!user) {
|
||||
console.error(`❌ User ${testUserEmail} not found. Please check existing users:`);
|
||||
const someUsers = await User.findAll({ limit: 5 });
|
||||
someUsers.forEach(u => console.log(` - ${u.email}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Create a MOCK Incoming Email record
|
||||
const mockMessageId = `mock-msg-${Date.now()}`;
|
||||
const mockBody = `
|
||||
APPROVE
|
||||
|
||||
Comments: This is a test approval from testuser10 via the mock script.
|
||||
The request looks good, please proceed with the next steps.
|
||||
|
||||
---
|
||||
Original Message:
|
||||
Request ID: ${requestNumber}
|
||||
`;
|
||||
|
||||
console.log(`📥 Creating mock email from ${testUserEmail}...`);
|
||||
const incomingEmail = await IncomingEmail.create({
|
||||
messageId: mockMessageId,
|
||||
threadId: 'mock-thread-123',
|
||||
from: `Test User 10 <${testUserEmail}>`,
|
||||
to: 'approvals@royalenfield.com',
|
||||
subject: `Re: Approval Request - ${requestNumber}`,
|
||||
body: mockBody,
|
||||
receivedAt: new Date(),
|
||||
processed: false
|
||||
} as any);
|
||||
|
||||
// 4. Manually trigger the private "applyWorkflowAction" logic
|
||||
console.log(`⚙️ Processing workflow action...`);
|
||||
await (gmailService as any).applyWorkflowAction(incomingEmail, {
|
||||
action: 'APPROVE',
|
||||
requestNumber: requestNumber,
|
||||
comments: 'This is a test approval from testuser10 via the mock script.'
|
||||
});
|
||||
|
||||
console.log('✅ Mock Approval Completed Successfully!');
|
||||
console.log('Check the WorkflowRequest status in your app (should be APPROVED or moving to next level).');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Test failed:', error);
|
||||
} finally {
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
mockEmailApproval();
|
||||
@ -185,6 +185,7 @@ export class EmailNotificationService {
|
||||
approverLevel: approverData.levelNumber,
|
||||
totalApprovers: approvalChain.length,
|
||||
approversList: chainData,
|
||||
showActionButtons: requestData.templateType === 'CUSTOM',
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name,
|
||||
customMessage
|
||||
@ -215,6 +216,7 @@ export class EmailNotificationService {
|
||||
priority: requestData.priority || 'MEDIUM',
|
||||
requestDate: this.formatDate(requestData.createdAt),
|
||||
requestTime: this.formatTime(requestData.createdAt),
|
||||
showActionButtons: requestData.templateType === 'CUSTOM',
|
||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||
companyName: CompanyInfo.name,
|
||||
customMessage
|
||||
|
||||
257
src/services/gmail.service.ts
Normal file
257
src/services/gmail.service.ts
Normal file
@ -0,0 +1,257 @@
|
||||
import { google } from 'googleapis';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { gmailConfig } from '../config/gmail.config';
|
||||
import { IncomingEmail } from '../models/IncomingEmail';
|
||||
import { WorkflowRequest } from '../models/WorkflowRequest';
|
||||
import { ApprovalLevel } from '../models/ApprovalLevel';
|
||||
import { User } from '../models/User';
|
||||
import { ApprovalService } from './approval.service';
|
||||
import { parseEmailAction } from '../utils/gmailParser';
|
||||
import { ApprovalAction } from '../types/approval.types';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const approvalService = new ApprovalService();
|
||||
|
||||
export class GmailService {
|
||||
private auth: any;
|
||||
private gmail: any;
|
||||
|
||||
constructor() {
|
||||
this.initAuth();
|
||||
}
|
||||
|
||||
private initAuth() {
|
||||
try {
|
||||
const keyPath = path.resolve(gmailConfig.serviceAccountPath);
|
||||
|
||||
// Use Service Account with Domain-Wide Delegation to impersonate the approval mailbox
|
||||
this.auth = new google.auth.JWT({
|
||||
keyFile: keyPath,
|
||||
scopes: ['https://www.googleapis.com/auth/gmail.modify', 'https://www.googleapis.com/auth/gmail.settings.basic'],
|
||||
subject: gmailConfig.impersonateEmail
|
||||
});
|
||||
|
||||
this.gmail = google.gmail({ version: 'v1', auth: this.auth });
|
||||
logger.info(`[GmailService] Initialized for ${gmailConfig.impersonateEmail}`);
|
||||
} catch (error) {
|
||||
logger.error(`[GmailService] Failed to initialize Google Auth:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup Gmail Watch for Pub/Sub notifications
|
||||
*/
|
||||
async setupWatch() {
|
||||
try {
|
||||
const res = await this.gmail.users.watch({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
labelIds: ['INBOX'],
|
||||
topicName: `projects/${process.env.GCP_PROJECT_ID}/topics/${gmailConfig.pubsubTopic}`
|
||||
}
|
||||
});
|
||||
logger.info(`[GmailService] Watch setup successfully:`, res.data);
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
logger.error(`[GmailService] Failed to setup watch:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process notification from Pub/Sub
|
||||
*/
|
||||
async processNotification(notificationData: any) {
|
||||
try {
|
||||
const { emailAddress, historyId } = notificationData;
|
||||
logger.info(`[GmailService] Received notification for ${emailAddress}, historyId: ${historyId}`);
|
||||
|
||||
// In a production environment, you might want to store the last processed historyId
|
||||
// and only fetch changes since then using users.history.list.
|
||||
// For simplicity in this requirement, we'll list the latest messages.
|
||||
|
||||
const res = await this.gmail.users.messages.list({
|
||||
userId: 'me',
|
||||
maxResults: 10,
|
||||
q: 'label:INBOX is:unread'
|
||||
});
|
||||
|
||||
const messages = res.data.messages || [];
|
||||
for (const msg of messages) {
|
||||
await this.processMessage(msg.id);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[GmailService] Error processing notification:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and process a single message
|
||||
*/
|
||||
private async processMessage(messageId: string) {
|
||||
try {
|
||||
// 1. Check if already processed
|
||||
const existing = await IncomingEmail.findOne({ where: { messageId } });
|
||||
if (existing && existing.processed) return;
|
||||
|
||||
// 2. Fetch full message
|
||||
const res = await this.gmail.users.messages.get({
|
||||
userId: 'me',
|
||||
id: messageId,
|
||||
format: 'full'
|
||||
});
|
||||
|
||||
const message = res.data;
|
||||
const headers = message.payload?.headers || [];
|
||||
const from = headers.find((h: any) => h.name?.toLowerCase() === 'from')?.value || '';
|
||||
const to = headers.find((h: any) => h.name?.toLowerCase() === 'to')?.value || '';
|
||||
const subject = headers.find((h: any) => h.name?.toLowerCase() === 'subject')?.value || '';
|
||||
|
||||
// Extract body
|
||||
let body = '';
|
||||
if (message.payload?.parts) {
|
||||
const textPart = message.payload.parts.find((p: any) => p.mimeType === 'text/plain');
|
||||
if (textPart && textPart.body?.data) {
|
||||
body = Buffer.from(textPart.body.data, 'base64').toString();
|
||||
}
|
||||
} else if (message.payload?.body?.data) {
|
||||
body = Buffer.from(message.payload.body.data, 'base64').toString();
|
||||
}
|
||||
|
||||
logger.info(`[GmailService] Processing message ${messageId} from ${from}: ${subject}`);
|
||||
|
||||
// 3. Save to database
|
||||
const incomingEmail = await IncomingEmail.create({
|
||||
messageId,
|
||||
threadId: message.threadId!,
|
||||
from,
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
receivedAt: new Date(parseInt(message.internalDate!)),
|
||||
processed: false
|
||||
} as any);
|
||||
|
||||
// 4. Parse Action
|
||||
const { action, requestNumber, comments } = parseEmailAction(subject, body);
|
||||
|
||||
if (action === 'NONE' || !requestNumber) {
|
||||
logger.info(`[GmailService] No action or request number found in message ${messageId}`);
|
||||
await incomingEmail.update({ processed: true, actionTaken: 'NONE' });
|
||||
// Mark as read anyway?
|
||||
await this.markAsRead(messageId);
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Apply Workflow Logic
|
||||
await this.applyWorkflowAction(incomingEmail, { action, requestNumber, comments });
|
||||
|
||||
// 6. Mark message as read/processed in Gmail
|
||||
await this.markAsRead(messageId);
|
||||
} catch (error) {
|
||||
logger.error(`[GmailService] Error processing message ${messageId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyWorkflowAction(incomingEmail: any, parsedAction: any) {
|
||||
const { action, requestNumber, comments } = parsedAction;
|
||||
|
||||
try {
|
||||
// 1. Find Request
|
||||
const request = await WorkflowRequest.findOne({ where: { requestNumber } });
|
||||
if (!request) {
|
||||
throw new Error(`Request ${requestNumber} not found`);
|
||||
}
|
||||
|
||||
// 2. Resolve User by Email
|
||||
// Extract email from "Name <email@domain.com>"
|
||||
const emailMatch = incomingEmail.from.match(/<(.+?)>/) || [null, incomingEmail.from];
|
||||
const approverEmail = emailMatch[1].trim();
|
||||
|
||||
const user = await User.findOne({ where: { email: approverEmail } });
|
||||
if (!user) {
|
||||
throw new Error(`User with email ${approverEmail} not found`);
|
||||
}
|
||||
|
||||
// 3. Find current pending level for this user and request
|
||||
const currentLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: request.requestId,
|
||||
approverId: user.userId,
|
||||
status: 'IN_PROGRESS' // Or PENDING if it was just assigned
|
||||
}
|
||||
});
|
||||
|
||||
if (!currentLevel) {
|
||||
// Broaden search to PENDING if not yet IN_PROGRESS (some implementations delay IN_PROGRESS)
|
||||
const pendingLevel = await ApprovalLevel.findOne({
|
||||
where: {
|
||||
requestId: request.requestId,
|
||||
approverId: user.userId,
|
||||
status: 'PENDING'
|
||||
}
|
||||
});
|
||||
|
||||
if (!pendingLevel) {
|
||||
throw new Error(`No pending/in-progress approval step found for user ${approverEmail} on request ${requestNumber}`);
|
||||
}
|
||||
|
||||
// Use pending level
|
||||
await this.executeApproval(request, pendingLevel, action, user, comments, incomingEmail);
|
||||
} else {
|
||||
await this.executeApproval(request, currentLevel, action, user, comments, incomingEmail);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error(`[GmailService] Workflow action failed for ${requestNumber}:`, error);
|
||||
await incomingEmail.update({
|
||||
processed: true,
|
||||
error: error.message,
|
||||
actionTaken: action
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async executeApproval(request: any, level: any, action: string, user: any, comments: string, incomingEmail: any) {
|
||||
const approvalAction: ApprovalAction = {
|
||||
action: action as any,
|
||||
comments: comments || `${action} via email`,
|
||||
rejectionReason: action === 'REJECT' ? (comments || 'Rejected via email') : undefined
|
||||
};
|
||||
|
||||
logger.info(`[GmailService] Executing ${action} for ${request.requestNumber} level ${level.levelNumber} by ${user.email}`);
|
||||
|
||||
await approvalService.approveLevel(
|
||||
level.levelId,
|
||||
approvalAction,
|
||||
user.userId,
|
||||
{ ipAddress: 'GMAIL_WEBHOOK', userAgent: 'Gmail/PubSub' }
|
||||
);
|
||||
|
||||
await incomingEmail.update({
|
||||
processed: true,
|
||||
actionTaken: action,
|
||||
parsedComments: comments,
|
||||
requestId: request.requestId
|
||||
});
|
||||
|
||||
logger.info(`[GmailService] ✅ Workflow ${request.requestNumber} updated successfully via email.`);
|
||||
}
|
||||
|
||||
private async markAsRead(messageId: string) {
|
||||
try {
|
||||
await this.gmail.users.messages.batchModify({
|
||||
userId: 'me',
|
||||
requestBody: {
|
||||
ids: [messageId],
|
||||
removeLabelIds: ['UNREAD']
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[GmailService] Failed to mark message ${messageId} as read:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gmailService = new GmailService();
|
||||
65
src/utils/gmailParser.ts
Normal file
65
src/utils/gmailParser.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import logger from './logger';
|
||||
|
||||
export interface EmailAction {
|
||||
action: 'APPROVE' | 'REJECT' | 'NONE';
|
||||
requestNumber: string | null;
|
||||
comments: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Gmail message content to extract workflow actions
|
||||
*/
|
||||
export function parseEmailAction(subject: string, body: string): EmailAction {
|
||||
const result: EmailAction = {
|
||||
action: 'NONE',
|
||||
requestNumber: null,
|
||||
comments: null
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Extract Request Number from Subject
|
||||
// Format expected: RE: [REQ-20260413-001] APPROVE
|
||||
const reqMatch = subject.match(/\[(REQ-.*?)\]/i);
|
||||
if (reqMatch) {
|
||||
result.requestNumber = reqMatch[1].toUpperCase();
|
||||
} else {
|
||||
// Try body if not in subject
|
||||
const bodyReqMatch = body.match(/Request ID:\s*(REQ-[^\s\r\n]*)/i);
|
||||
if (bodyReqMatch) {
|
||||
result.requestNumber = bodyReqMatch[1].toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Extract Action from Subject or Body
|
||||
const actionMatch = subject.match(/\b(APPROVE|REJECT)\b/i);
|
||||
const bodyActionMatch = body.match(/^\s*(APPROVE|REJECT)\b/im); // First line action
|
||||
|
||||
if (actionMatch) {
|
||||
result.action = actionMatch[1].toUpperCase() as any;
|
||||
} else if (bodyActionMatch) {
|
||||
result.action = bodyActionMatch[1].toUpperCase() as any;
|
||||
}
|
||||
|
||||
// 3. Extract Comments
|
||||
// Take everything after "Comments:" or "Reason:"
|
||||
const commentsMatch = body.match(/(?:Comments|Reason):\s*([\s\S]*)/i);
|
||||
if (commentsMatch) {
|
||||
result.comments = commentsMatch[1].trim();
|
||||
} else if (result.action !== 'NONE') {
|
||||
// If no "Comments:" tag, take the rest of the body ignoring the action line
|
||||
const lines = body.split('\n');
|
||||
const actionLineIndex = lines.findIndex(l => l.toUpperCase().includes(result.action));
|
||||
if (actionLineIndex !== -1) {
|
||||
const remaining = lines.slice(actionLineIndex + 1).join('\n').trim();
|
||||
if (remaining && !remaining.startsWith('---')) {
|
||||
result.comments = remaining;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`[GmailParser] Error parsing email:`, error);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user