Compare commits
2 Commits
16deafd42d
...
bb1feb5538
| Author | SHA1 | Date | |
|---|---|---|---|
| bb1feb5538 | |||
| ec04538fa1 |
@ -1 +1 @@
|
|||||||
import{a as s}from"./index-B4PRp9Lp.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
import{a as s}from"./index-D4LuhKhf.js";import"./radix-vendor-CLtqm-Ae.js";import"./charts-vendor-CmYZJIYl.js";import"./utils-vendor-BTBPSQfW.js";import"./ui-vendor-DgwXkk2Y.js";import"./socket-vendor-TjCxX7sJ.js";import"./redux-vendor-tbZCm13o.js";import"./router-vendor-HW_ujxKo.js";async function m(n){return(await s.post(`/conclusions/${n}/generate`)).data.data}async function f(n,t){return(await s.post(`/conclusions/${n}/finalize`,{finalRemark:t})).data.data}async function d(n){var t;try{return(await s.get(`/conclusions/${n}`)).data.data}catch(o){if(((t=o.response)==null?void 0:t.status)===404)return null;throw o}}export{f as finalizeConclusion,m as generateConclusion,d as getConclusion};
|
||||||
File diff suppressed because one or more lines are too long
@ -13,7 +13,7 @@
|
|||||||
<!-- Preload essential fonts and icons -->
|
<!-- Preload essential fonts and icons -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<script type="module" crossorigin src="/assets/index-B4PRp9Lp.js"></script>
|
<script type="module" crossorigin src="/assets/index-D4LuhKhf.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-CmYZJIYl.js">
|
<link rel="modulepreload" crossorigin href="/assets/charts-vendor-CmYZJIYl.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CLtqm-Ae.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-vendor-CLtqm-Ae.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
<link rel="modulepreload" crossorigin href="/assets/utils-vendor-BTBPSQfW.js">
|
||||||
|
|||||||
191
package-lock.json
generated
191
package-lock.json
generated
@ -25,6 +25,7 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -62,6 +63,7 @@
|
|||||||
"@types/node": "^22.19.1",
|
"@types/node": "^22.19.1",
|
||||||
"@types/passport": "^1.0.16",
|
"@types/passport": "^1.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
@ -4083,6 +4085,16 @@
|
|||||||
"@types/passport": "*"
|
"@types/passport": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/pdf-parse": {
|
||||||
|
"version": "1.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.5.tgz",
|
||||||
|
"integrity": "sha512-kBfrSXsloMnUJOKi25s3+hRmkycHfLK6A09eRGqF/N8BkQoPUmaCr+q8Cli5FnfohEz/rsv82zAiPz/LXtOGhA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/pg": {
|
"node_modules/@types/pg": {
|
||||||
"version": "8.15.6",
|
"version": "8.15.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz",
|
||||||
@ -7823,6 +7835,179 @@
|
|||||||
"node": ">=14"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@ -12844,6 +13029,12 @@
|
|||||||
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
"integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
|
|||||||
@ -48,6 +48,7 @@
|
|||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"express-rate-limit": "^7.5.0",
|
"express-rate-limit": "^7.5.0",
|
||||||
"fast-xml-parser": "^5.3.3",
|
"fast-xml-parser": "^5.3.3",
|
||||||
|
"googleapis": "^171.4.0",
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"ioredis": "^5.8.2",
|
"ioredis": "^5.8.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
@ -85,6 +86,7 @@
|
|||||||
"@types/node": "^22.19.1",
|
"@types/node": "^22.19.1",
|
||||||
"@types/passport": "^1.0.16",
|
"@types/passport": "^1.0.16",
|
||||||
"@types/passport-jwt": "^4.0.1",
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/pdf-parse": "^1.1.5",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.15.6",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.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 { 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';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
||||||
return `
|
return `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@ -25,9 +25,9 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
|||||||
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
<table role="presentation" class="email-container" style="${getEmailContainerStyles()}" cellpadding="0" cellspacing="0">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
${getEmailHeader(getBrandedHeader({
|
${getEmailHeader(getBrandedHeader({
|
||||||
title: 'Approval Request',
|
title: 'Approval Request',
|
||||||
...HeaderStyles.info
|
...HeaderStyles.info
|
||||||
}))}
|
}))}
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<tr>
|
<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.
|
<strong style="color: #333333;">${data.initiatorName}</strong> has submitted a request that requires your approval.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Request Details Box -->
|
<!-- Request Summary (FR-2.2.3) -->
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #f8f9fa; border-radius: 6px; margin-bottom: 30px;" cellpadding="0" cellspacing="0">
|
<div style="margin-bottom: 30px;">
|
||||||
<tr>
|
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Request Summary:</h3>
|
||||||
<td style="padding: 25px;">
|
${getRequestSummaryTable({
|
||||||
<h2 style="margin: 0 0 20px; color: #333333; font-size: 18px; font-weight: 600;">Request Details</h2>
|
priority: data.priority,
|
||||||
|
requestType: data.requestType,
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse;" cellpadding="0" cellspacing="0">
|
purpose: data.requestDescription
|
||||||
<tr>
|
})}
|
||||||
<td style="padding: 8px 0; color: #666666; font-size: 14px; width: 140px;">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Custom Message Section -->
|
<!-- Custom Message Section -->
|
||||||
${getCustomMessageSection(data.customMessage)}
|
${getCustomMessageSection(data.customMessage)}
|
||||||
@ -116,6 +64,17 @@ export function getApprovalRequestEmail(data: ApprovalRequestData): string {
|
|||||||
<!-- Priority Section (dynamic) -->
|
<!-- Priority Section (dynamic) -->
|
||||||
${getPrioritySection(data.priority)}
|
${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 -->
|
<!-- View Details Button -->
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<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 {
|
export function getApprovalChainTable(approvers: ApprovalChainItem[]): string {
|
||||||
return approvers.map(approver => {
|
if (!approvers || approvers.length === 0) return '';
|
||||||
let icon = '';
|
|
||||||
let textColor = '#333333';
|
const rows = approvers.map(approver => {
|
||||||
let status = '';
|
let statusLabel = '';
|
||||||
|
let statusColor = '#333333';
|
||||||
|
let bgColor = '#ffffff';
|
||||||
|
|
||||||
switch (approver.status) {
|
switch (approver.status) {
|
||||||
case 'approved':
|
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>`;
|
statusLabel = 'Approved';
|
||||||
status = approver.date ? `Approved on ${approver.date}` : 'Approved';
|
statusColor = '#28a745';
|
||||||
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)';
|
|
||||||
break;
|
break;
|
||||||
case 'pending':
|
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>`;
|
statusLabel = 'Pending';
|
||||||
status = 'Pending';
|
statusColor = '#ffc107';
|
||||||
|
break;
|
||||||
|
case 'current':
|
||||||
|
statusLabel = 'Pending (Action Required)';
|
||||||
|
statusColor = '#007bff';
|
||||||
|
bgColor = '#f0f7ff';
|
||||||
break;
|
break;
|
||||||
case 'awaiting':
|
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>`;
|
statusLabel = 'Awaiting';
|
||||||
textColor = '#999999';
|
statusColor = '#6c757d';
|
||||||
status = 'Awaiting';
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="padding: 10px 0; border-bottom: 1px solid #e9ecef;">
|
<tr style="background-color: ${bgColor}; border-bottom: 1px solid #dee2e6;">
|
||||||
${icon}
|
<td style="padding: 12px 15px; font-size: 14px; color: #333333;">Level ${approver.levelNumber}</td>
|
||||||
<strong style="color: ${textColor};">${approver.name}</strong> - ${status}
|
<td style="padding: 12px 15px; font-size: 14px; color: #333333;"><strong>${approver.name}</strong></td>
|
||||||
</div>
|
<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('');
|
}).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 { 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';
|
import { getBrandedHeader } from './branding.config';
|
||||||
|
|
||||||
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
|
export function getMultiApproverRequestEmail(data: MultiApproverRequestData): string {
|
||||||
@ -94,16 +94,20 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
|||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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;">
|
<div style="margin-bottom: 30px;">
|
||||||
<h3 style="margin: 0 0 15px; color: #333333; font-size: 16px; font-weight: 600;">Approval Chain:</h3>
|
<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">
|
${getApprovalChainTable(data.approversList)}
|
||||||
<tr>
|
|
||||||
<td style="padding: 20px;">
|
|
||||||
${getApprovalChain(data.approversList)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description (supports rich text HTML including tables) -->
|
<!-- Description (supports rich text HTML including tables) -->
|
||||||
@ -124,6 +128,17 @@ export function getMultiApproverRequestEmail(data: MultiApproverRequestData): st
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- View Details Button -->
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; margin-bottom: 20px;" cellpadding="0" cellspacing="0">
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@ -48,12 +48,14 @@ export interface ApprovalRequestData extends BaseEmailData {
|
|||||||
requestDescription: string;
|
requestDescription: string;
|
||||||
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
|
||||||
customMessage?: string;
|
customMessage?: string;
|
||||||
|
showActionButtons?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MultiApproverRequestData extends ApprovalRequestData {
|
export interface MultiApproverRequestData extends ApprovalRequestData {
|
||||||
approverLevel: number;
|
approverLevel: number;
|
||||||
totalApprovers: number;
|
totalApprovers: number;
|
||||||
approversList: ApprovalChainItem[];
|
approversList: ApprovalChainItem[];
|
||||||
|
showActionButtons?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalChainItem {
|
export interface ApprovalChainItem {
|
||||||
@ -61,6 +63,7 @@ export interface ApprovalChainItem {
|
|||||||
status: 'approved' | 'pending' | 'current' | 'awaiting';
|
status: 'approved' | 'pending' | 'current' | 'awaiting';
|
||||||
date?: string;
|
date?: string;
|
||||||
levelNumber: number;
|
levelNumber: number;
|
||||||
|
comments?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApprovalConfirmationData extends BaseEmailData {
|
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 dealerExternalRoutes from './dealerExternal.routes';
|
||||||
import form16Routes from './form16.routes';
|
import form16Routes from './form16.routes';
|
||||||
import hsnSacCodeRoutes from './hsnSacCode.routes';
|
import hsnSacCodeRoutes from './hsnSacCode.routes';
|
||||||
|
import gmailRoutes from './gmail.routes';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -81,6 +82,7 @@ router.use('/ai', aiLimiter, aiRoutes); // 20 re
|
|||||||
|
|
||||||
// ── External webhooks (burst-friendly) ──
|
// ── External webhooks (burst-friendly) ──
|
||||||
router.use('/webhooks/dms', webhookLimiter, dmsWebhookRoutes); // 100 req/15min
|
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) ──
|
// ── 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)
|
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 m68 = require('../migrations/20260325090001-ensure-pan-number-in-26as');
|
||||||
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
|
const m69 = require('../migrations/20260325094500-add-user-session-and-hsn-sac-codes');
|
||||||
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
|
const m70 = require('../migrations/20260325175000-update-credit-notes-and-add-items');
|
||||||
|
const m71 = require('../migrations/20260413-create-incoming-emails');
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{ name: '2025103000-create-users', module: m0 },
|
{ 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: '20260325090001-ensure-pan-number-in-26as', module: m68 },
|
||||||
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
{ name: '20260325094500-add-user-session-and-hsn-sac-codes', module: m69 },
|
||||||
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
{ name: '20260325175000-update-credit-notes-and-add-items', module: m70 },
|
||||||
|
{ name: '20260413-create-incoming-emails', module: m71 },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Dynamically import sequelize after secrets are loaded
|
// 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,
|
approverLevel: approverData.levelNumber,
|
||||||
totalApprovers: approvalChain.length,
|
totalApprovers: approvalChain.length,
|
||||||
approversList: chainData,
|
approversList: chainData,
|
||||||
|
showActionButtons: requestData.templateType === 'CUSTOM',
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
companyName: CompanyInfo.name,
|
companyName: CompanyInfo.name,
|
||||||
customMessage
|
customMessage
|
||||||
@ -215,6 +216,7 @@ export class EmailNotificationService {
|
|||||||
priority: requestData.priority || 'MEDIUM',
|
priority: requestData.priority || 'MEDIUM',
|
||||||
requestDate: this.formatDate(requestData.createdAt),
|
requestDate: this.formatDate(requestData.createdAt),
|
||||||
requestTime: this.formatTime(requestData.createdAt),
|
requestTime: this.formatTime(requestData.createdAt),
|
||||||
|
showActionButtons: requestData.templateType === 'CUSTOM',
|
||||||
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
viewDetailsLink: getViewDetailsLink(requestData.requestNumber, this.frontendUrl),
|
||||||
companyName: CompanyInfo.name,
|
companyName: CompanyInfo.name,
|
||||||
customMessage
|
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