Add modular Jira-to-Webex approvals service with Docker deployment.

Stabilize logging, correlation IDs, service-account Jira auth, and direct approver reminder DMs while splitting the monolith into focused modules with Compose-based deployment.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-17 08:34:47 -04:00
commit 0b056739e9
24 changed files with 2414 additions and 0 deletions

9
.dockerignore Normal file
View file

@ -0,0 +1,9 @@
node_modules
logs
.env
.env.local
.git
.gitignore
.DS_Store
*.md
config/config.local.json

24
.env.example Normal file
View file

@ -0,0 +1,24 @@
# Server
PORT=1345
SERVER_NAME=aeoApprovals
LOG_LEVEL=info
# Jira Cloud — service account (same pattern as wxccai)
# Preferred: set JIRA_CLOUD_ID to use the api.atlassian.com gateway.
JIRA_CLOUD_ID=
JIRA_BASE_URL=https://aeo.atlassian.net
JIRA_AUTH_TYPE=basic
JIRA_EMAIL=your-bot@serviceaccount.atlassian.com
JIRA_API_TOKEN=
# Webex bots
WEBEX_TOKEN=
JIRA_NOTIFIER_TOKEN=
# Webex rooms
WEBEX_ROOM_ID=
APPROVAL_ROOM_ID=
SUPPORT_ROOM_ID=
# Only set if you have a specific TLS/certificate issue to work around
# NODE_TLS_REJECT_UNAUTHORIZED=0

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
node_modules/
logs/
.env
.env.local
.env.*.local
.DS_Store
# Local secrets — use config.example.json + .env instead
config/config.json
config/config.local.json

13
Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM node:20
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 1345
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 1345) + '/healthCheck').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD [ "node", "index.js" ]

8
app.js Normal file
View file

@ -0,0 +1,8 @@
import express from 'express';
import bodyParser from 'body-parser';
export function createApp() {
const app = express();
app.use(bodyParser.json({ limit: '200mb' }));
return app;
}

View file

@ -0,0 +1,25 @@
{
"logLevel": "info",
"webex": {
"whUrl": "http://localhost:1345/",
"botName": "AEO Approvals",
"botUserName": "aeoapprovals@webex.bot",
"room": {
"title": "Pending Approvals | Testing"
}
},
"server": {
"port": "1345",
"name": "aeoApprovals"
},
"rooms": {
"approval": "your-approval-room-id",
"support": "your-support-room-id"
},
"jiraCloud": {
"cloudId": "your-atlassian-cloud-id",
"host": "https://aeo.atlassian.net",
"authType": "basic",
"email": "your-bot@serviceaccount.atlassian.com"
}
}

226
config/requests.json Normal file
View file

@ -0,0 +1,226 @@
{
"177": {
"name": "Request a Change",
"transitions": {
"171": {
"name": "Ready for Approval"
},
"271": {
"name": "Emergency"
}
}
},
"193": {
"name": "Onboard New Hire / Modify Existing User",
"transitions": {
"151": {
"name": "Manager Approval"
},
"261": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"209": {
"name": "Application Access",
"transitions": {
"1111": {
"name": "Manager Approval"
},
"1011": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"202": {
"name": "Enterprise Access",
"transitions": {
"1111": {
"name": "Manager Approval"
},
"1011": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"189": {
"name": "IBMi Access",
"transitions": {
"1111": {
"name": "Manager Approval"
},
"1011": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"188": {
"name": "DI Access",
"transitions": {
"81": {
"name": "Manager Approval"
},
"171": {
"name": "Escalate to Tier 2 Manager"
},
"101": {
"name": "Finance Approval"
}
}
},
"183": {
"name": "Firewall Access",
"transitions": {
"1071": {
"name": "Ready for Approvals"
}
}
},
"191": {
"name": "Desktop Requests",
"transitions": {
"81": {
"name": "Manager Approval"
},
"221": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"382": {
"name": "Service Request",
"transitions": {
"81": {
"name": "Manager Approval"
},
"101": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"211": {
"name": "Database Access",
"transitions": {
"981": {
"name": "Manager Approval"
},
"991": {
"name": "Business Approval"
},
"1001": {
"name": "Technical Approval"
},
"1051": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"192": {
"name": "Contractor Conversion",
"transitions": {
"151": {
"name": "Manager Approval"
},
"261": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"197": {
"name": "Control M Job Request",
"transitions": {
"81": {
"name": "Manager Approval"
},
"101": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"200": {
"name": "Corporate Credit Card Request",
"transitions": {
"11": {
"name": "Manager Approval"
},
"121": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"203": {
"name": "Clarity Onboarding",
"transitions": {
"11": {
"name": "Manager Approval"
},
"61": {
"name": "PM Approval"
},
"21": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"190": {
"name": "Server Access",
"transitions": {
"11": {
"name": "Pending Approval"
},
"141": {
"name": "Pending Approval"
}
}
},
"199": {
"name": "Contractor Extension",
"transitions": {
"1111": {
"name": "Manager Approval"
},
"1011": {
"name": "Escalate to Tier 2 Manager"
}
}
},
"179": {
"name": "Customer Technology Production Change",
"transitions": {
"1071": {
"name": "Ready for Approvals"
}
}
},
"204": {
"name": "Server Decommission Request",
"transitions": {
"141": {
"name": "Pending Approval"
},
"11": {
"name": "Pending Approval"
}
}
},
"186": {
"name": "Request Store Fixtures",
"transitions": {
"21": {
"name": "Finance Approval"
}
}
},
"187": {
"name": "New Server Build Request",
"transitions": {
"141": {
"name": "Pending Approval"
},
"211": {
"name": "Technical Approval"
}
}
}
}

35
docker-compose.yml Normal file
View file

@ -0,0 +1,35 @@
services:
aeo-approvals:
build: .
container_name: aeo-approvals
ports:
- "${PORT:-1345}:1345"
env_file:
- path: .env
required: false
environment:
PORT: ${PORT:-1345}
LOG_LEVEL: ${LOG_LEVEL:-info}
SERVER_NAME: ${SERVER_NAME:-aeoApprovals}
JIRA_CLOUD_ID: ${JIRA_CLOUD_ID:-}
JIRA_BASE_URL: ${JIRA_BASE_URL:-https://aeo.atlassian.net}
JIRA_AUTH_TYPE: ${JIRA_AUTH_TYPE:-basic}
JIRA_EMAIL: ${JIRA_EMAIL:-}
JIRA_API_TOKEN: ${JIRA_API_TOKEN:-}
volumes:
- ./logs:/usr/src/app/logs
- ./config/config.json:/usr/src/app/config/config.json:ro
- ./config/requests.json:/usr/src/app/config/requests.json:ro
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:' + (process.env.PORT || 1345) + '/healthCheck').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s

64
index.js Normal file
View file

@ -0,0 +1,64 @@
import fs from 'fs';
import { loadConfig, loadRequests, getMissingSecrets, describeJiraIdentity } from './lib/config.js';
import { createLogger } from './lib/logger.js';
import { createJiraClient } from './services/jiraClient.js';
import { createJiraService } from './services/jira.js';
import { createWebexService } from './services/webex.js';
import { createJiraProcessor } from './services/jiraProcessor.js';
import { createPendingApprovalsJob, cleanupOldLogs } from './jobs/pendingApprovals.js';
import { scheduleJobs } from './jobs/cron.js';
import { createWebhookRoutes } from './routes/webhooks.js';
import { createApp } from './app.js';
const config = loadConfig();
const requests = loadRequests();
const log = createLogger(config);
if (!fs.existsSync('./logs')) {
fs.mkdirSync('./logs', { recursive: true });
}
const missingSecrets = getMissingSecrets(config);
if (missingSecrets.length > 0) {
log.writeLog('warn', 'startup', `Missing configuration — set env vars or config.json: ${missingSecrets.join(', ')}`);
}
if (process.env.NODE_TLS_REJECT_UNAUTHORIZED === '0') {
log.writeLog('warn', 'startup', 'NODE_TLS_REJECT_UNAUTHORIZED=0 is set — TLS certificate verification is disabled');
}
log.logger('startup', `Jira identity: ${describeJiraIdentity(config)}`);
const jiraClient = createJiraClient(config);
const jiraService = createJiraService(jiraClient, log);
const webexService = createWebexService(config, log);
const jiraProcessor = createJiraProcessor({ config, requests, jiraService, webexService, log });
const pendingApprovalsJob = createPendingApprovalsJob({ jiraService, webexService, log });
scheduleJobs({
runPendingApprovals: pendingApprovalsJob.runPendingApprovals,
cleanupOldLogs: () => cleanupOldLogs(log),
log,
});
const app = createApp();
const webhookRoutes = createWebhookRoutes({ config, jiraProcessor, webexService, log });
webhookRoutes.registerRoutes(app);
const server = app.listen(config.server.port, () => {
log.logger('startup', `${config.server.name} running on port ${config.server.port}.`);
});
process.on('SIGINT', () => {
server.close(() => {
log.logger('shutdown', `${config.server.name} stopped!`);
process.exit();
});
});
process.on('unhandledRejection', (reason) => {
log.logError('process', 'Unhandled promise rejection', reason);
});
process.on('uncaughtException', (err) => {
log.logError('process', 'Uncaught exception — shutting down', err);
process.exit(1);
});

18
jobs/cron.js Normal file
View file

@ -0,0 +1,18 @@
import cron from 'node-cron';
import { createCorrelationId, runWithCorrelationId } from '../lib/correlation.js';
export function scheduleJobs({ runPendingApprovals, cleanupOldLogs, log }) {
cron.schedule('0 30 9 * * *', async function () {
const correlationId = createCorrelationId({ routeName: 'cron' });
await runWithCorrelationId(correlationId, async () => {
try {
log.logger('cron', 'Starting scheduled job');
await runPendingApprovals();
await cleanupOldLogs();
log.logger('cron', 'Scheduled job completed');
} catch (error) {
log.logError('cron', 'Scheduled job failed', error);
}
});
});
}

100
jobs/pendingApprovals.js Normal file
View file

@ -0,0 +1,100 @@
import fs from 'fs';
import path from 'path';
import { formatDuration } from '../lib/util.js';
export function createPendingApprovalsJob({ jiraService, webexService, log }) {
const { logger, logWarn, logError, logDebug } = log;
function sendApprovalReminder(personEmail, approvals) {
return new Promise(function (resolve, reject) {
let msgText = 'You have the following pending approvals:\n';
for (const approval of approvals) {
msgText += `- [${approval.key}](https://aeo.atlassian.net/servicedesk/customer/portal/135/${approval.key}): ${approval.summary} ${approval.duration}\n`;
}
msgText += '\nYou can find these in the [Requests Pending Approvals](https://aeo.atlassian.net/servicedesk/customer/user/approvals?page=1) portal. If you have any questions or experience issues, please submit a [support request](https://aeo.atlassian.net/servicedesk/customer/portals).';
logDebug('sendApprovalReminder', `Sending direct reminder to ${personEmail}:\n${msgText}`);
webexService.sendWebexMessage({
toPersonEmail: personEmail,
markdown: msgText,
})
.then(result => resolve(result))
.catch(error => reject(error));
});
}
function runPendingApprovals() {
return jiraService.searchUnapprovedRequests()
.then(async function (tickets) {
if (!tickets || tickets.length === 0) {
logger('runPendingApprovals', 'No pending approval tickets found.');
return;
}
const needApproval = {};
for (const ticket of tickets) {
if (ticket.fields.customfield_10033) {
for (const approvals of ticket.fields.customfield_10033) {
if (approvals.approvers.length > 0 && approvals.finalDecision == 'pending') {
for (const approver of approvals.approvers) {
if (approver.approverDecision != 'approved' && new Date(approvals.createdDate.iso8601) < new Date(new Date() - (24 * 60 * 60 * 1000))) {
if (!needApproval[approver.approver.emailAddress]) {
needApproval[approver.approver.emailAddress] = [];
}
needApproval[approver.approver.emailAddress].push({
key: ticket.key,
summary: ticket.fields.summary,
reporter: ticket.fields.reporter.displayName,
duration: formatDuration(approvals.createdDate.iso8601),
});
}
}
}
}
} else {
logWarn('runPendingApprovals', `No approvals setup in customfield_10033 for ${ticket.key}.`);
}
}
for (const approver in needApproval) {
await sendApprovalReminder(approver, needApproval[approver])
.then(() => logger('runPendingApprovals', `Sent reminder to ${approver}.`))
.catch(error => logError('runPendingApprovals', `Failed to send reminder to ${approver}`, error));
}
})
.catch(error => logError('runPendingApprovals', 'Failed to run pending approvals job', error));
}
return { runPendingApprovals };
}
export async function cleanupOldLogs(log) {
const { logger, logError } = log;
const now = Date.now();
const directory = './logs';
const sevenDaysInMs = 7 * 24 * 60 * 60 * 1000;
let files;
try {
files = await fs.promises.readdir(directory);
} catch (error) {
logError('cleanupOldLogs', `Failed to read log directory ${directory}`, error);
return;
}
for (const file of files) {
const filePath = path.join(directory, file);
try {
const stats = await fs.promises.stat(filePath);
if (stats.isFile() && (now - stats.mtimeMs) > sevenDaysInMs) {
await fs.promises.unlink(filePath);
logger('cleanupOldLogs', `Deleted old log file: ${file}`);
}
} catch (error) {
logError('cleanupOldLogs', `Failed to process log file ${file}`, error);
}
}
}

91
lib/config.js Normal file
View file

@ -0,0 +1,91 @@
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config();
function envOrConfig(envKey, configValue) {
const envValue = process.env[envKey];
if (envValue !== undefined && envValue !== '') {
return envValue;
}
return configValue;
}
function resolveJiraHost(fileConfig) {
const cloudId = envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId)?.trim();
let baseUrl = envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net')?.trim();
if (cloudId) {
return `https://api.atlassian.com/ex/jira/${cloudId}`;
}
return baseUrl.replace(/\/$/, '');
}
export function loadConfig() {
const fileConfig = JSON.parse(fs.readFileSync('./config/config.json'));
const config = {
...fileConfig,
logLevel: envOrConfig('LOG_LEVEL', fileConfig.logLevel),
server: {
...fileConfig.server,
port: envOrConfig('PORT', fileConfig.server?.port),
name: envOrConfig('SERVER_NAME', fileConfig.server?.name),
},
webex: {
...fileConfig.webex,
token: envOrConfig('WEBEX_TOKEN', fileConfig.webex?.token),
room: {
...fileConfig.webex?.room,
id: envOrConfig('WEBEX_ROOM_ID', fileConfig.webex?.room?.id),
},
},
jiraNotifier: {
...fileConfig.jiraNotifier,
token: envOrConfig('JIRA_NOTIFIER_TOKEN', fileConfig.jiraNotifier?.token),
},
jiraCloud: {
...fileConfig.jiraCloud,
cloudId: envOrConfig('JIRA_CLOUD_ID', fileConfig.jiraCloud?.cloudId) || null,
host: resolveJiraHost(fileConfig),
siteUrl: (envOrConfig('JIRA_BASE_URL', fileConfig.jiraCloud?.host || 'https://aeo.atlassian.net') || '').replace(/\/$/, ''),
authType: (envOrConfig('JIRA_AUTH_TYPE', fileConfig.jiraCloud?.authType) || 'basic').toLowerCase(),
email: envOrConfig('JIRA_EMAIL', fileConfig.jiraCloud?.email),
token: envOrConfig('JIRA_API_TOKEN', fileConfig.jiraCloud?.token),
},
rooms: {
approval: envOrConfig(
'APPROVAL_ROOM_ID',
fileConfig.rooms?.approval || 'Y2lzY29zcGFyazovL3VzL1JPT00vZWQ4ZWY3OTAtMGE5NC0xMWVjLWExZDUtM2I5ZWNlNWFlNDI5'
),
support: envOrConfig(
'SUPPORT_ROOM_ID',
fileConfig.rooms?.support || 'Y2lzY29zcGFyazovL3VzL1JPT00vZWY4MmExNjAtNzYzNS0xMWU4LWFlYWMtNjNjMGMyMTExYTVm'
),
},
};
return config;
}
export function loadRequests() {
return JSON.parse(fs.readFileSync('./config/requests.json'));
}
export function getMissingSecrets(config) {
const missing = [];
if (!config.jiraCloud?.email) missing.push('JIRA_EMAIL');
if (!config.jiraCloud?.token) missing.push('JIRA_API_TOKEN');
if (!config.webex?.token) missing.push('WEBEX_TOKEN');
if (!config.jiraNotifier?.token) missing.push('JIRA_NOTIFIER_TOKEN');
return missing;
}
export function describeJiraIdentity(config) {
const email = config.jiraCloud?.email || '(not set)';
const host = config.jiraCloud?.host || '(not set)';
const authType = config.jiraCloud?.authType || 'basic';
const viaCloudId = config.jiraCloud?.cloudId ? `cloudId=${config.jiraCloud.cloudId}` : 'site URL';
return `${email} (${authType}, ${viaCloudId}) → ${host}`;
}

23
lib/correlation.js Normal file
View file

@ -0,0 +1,23 @@
import { AsyncLocalStorage } from 'async_hooks';
import { randomUUID } from 'crypto';
const correlationStore = new AsyncLocalStorage();
export function createCorrelationId({ issueKey, routeName } = {}) {
const shortId = randomUUID().split('-')[0];
if (issueKey) {
return `${issueKey}-${shortId}`;
}
if (routeName) {
return `${routeName}-${shortId}`;
}
return shortId;
}
export function getCorrelationId() {
return correlationStore.getStore();
}
export function runWithCorrelationId(correlationId, fn) {
return correlationStore.run(correlationId, fn);
}

88
lib/logger.js Normal file
View file

@ -0,0 +1,88 @@
import fs from 'fs';
import path from 'path';
import { createCorrelationId, getCorrelationId, runWithCorrelationId } from './correlation.js';
const LOG_LEVELS = { error: 0, warn: 1, info: 2, debug: 3 };
export function createLogger(config) {
const currentLogLevel = LOG_LEVELS[config.logLevel?.toLowerCase()] ?? LOG_LEVELS.info;
function formatError(error) {
if (!error) return 'Unknown error';
if (typeof error === 'string') return error;
if (error.response?.data) {
return `${error.message || 'Request failed'}: ${JSON.stringify(error.response.data)}`;
}
return error.stack || error.message || String(error);
}
function correlationPrefix() {
const correlationId = getCorrelationId();
return correlationId ? `[${correlationId}] ` : '';
}
function writeLog(level, activeFunction, message) {
if (LOG_LEVELS[level] > currentLogLevel) return;
const line = `${new Date().toISOString()} [${level.toUpperCase()}] ${correlationPrefix()}${activeFunction}: ${message}`;
if (level === 'error') console.error(line);
else if (level === 'warn') console.warn(line);
else console.log(line);
}
function logger(activeFunction, logLine) {
writeLog('info', activeFunction, logLine);
}
function logWarn(activeFunction, logLine) {
writeLog('warn', activeFunction, logLine);
}
function logError(activeFunction, logLine, error) {
const detail = error !== undefined ? ` ${formatError(error)}` : '';
writeLog('error', activeFunction, `${logLine}${detail}`);
}
function logDebug(activeFunction, logLine) {
writeLog('debug', activeFunction, logLine);
}
function safeProcess(context, fn, meta = {}) {
const correlationId = meta.correlationId || createCorrelationId(meta);
runWithCorrelationId(correlationId, () => {
Promise.resolve()
.then(fn)
.catch(error => logError(context, 'Unhandled error during async processing', error));
});
}
function logFile(provider, jsonData) {
const d = new Date();
const year = d.getFullYear();
const month = (d.getMonth() + 1).toString().padStart(2, '0');
const day = d.getDate().toString().padStart(2, '0');
const logFilePath = path.join(`./logs/${provider}-${year}${month}${day}.log`);
const correlationId = getCorrelationId();
const correlationLine = correlationId ? ` [${correlationId}]` : '';
const content = `${d.toISOString()}${correlationLine}\n${JSON.stringify(jsonData)}\n`;
fs.appendFile(logFilePath, content, (error) => {
if (error) {
logError('logFile', `Failed to write ${provider} webhook log`, error);
}
});
}
return {
logger,
logWarn,
logError,
logDebug,
safeProcess,
logFile,
writeLog,
formatError,
runWithCorrelationId,
createCorrelationId,
getCorrelationId,
};
}

30
lib/util.js Normal file
View file

@ -0,0 +1,30 @@
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
export function formatDuration(timestamp, locale = 'en') {
let value;
const diff = (new Date().getTime() - new Date(timestamp).getTime()) / 1000;
const minutes = Math.floor(diff / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (years > 0) {
value = rtf.format(0 - years, 'year');
} else if (months > 0) {
value = rtf.format(0 - months, 'month');
} else if (days > 0) {
value = rtf.format(0 - days, 'day');
} else if (hours > 0) {
value = rtf.format(0 - hours, 'hour');
} else if (minutes > 0) {
value = rtf.format(0 - minutes, 'minute');
} else {
value = rtf.format(0 - diff, 'second');
}
return `(Submitted ${value})`;
}

23
lib/validate.js Normal file
View file

@ -0,0 +1,23 @@
export function validateJiraWebhook(body) {
if (!body || typeof body !== 'object') {
return { valid: false, error: 'Payload must be a JSON object' };
}
if (!body.issue?.key || typeof body.issue.key !== 'string') {
return { valid: false, error: 'Missing or invalid issue.key' };
}
if (!body.issue.fields || typeof body.issue.fields !== 'object') {
return { valid: false, error: 'Missing issue.fields' };
}
return { valid: true, issueKey: body.issue.key };
}
export function validateSupportWebhook(body) {
const base = validateJiraWebhook(body);
if (!base.valid) {
return base;
}
if (!body.webhookEvent || typeof body.webhookEvent !== 'string') {
return { valid: false, error: 'Missing or invalid webhookEvent' };
}
return { valid: true, issueKey: base.issueKey };
}

1128
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

21
package.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "aeorequests",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"type": "module",
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"body-parser": "^2.2.0",
"dotenv": "^17.4.2",
"express": "^5.1.0",
"jira.js": "^5.3.0",
"node-cron": "^4.2.1",
"node-fetch": "^3.3.2"
}
}

68
routes/webhooks.js Normal file
View file

@ -0,0 +1,68 @@
import { validateJiraWebhook, validateSupportWebhook } from '../lib/validate.js';
const WEBHOOK_LOG_PROVIDERS = {
'jira-request': 'jira',
request: 'request',
};
export function createWebhookRoutes({ config, jiraProcessor, webexService, log }) {
const { logger, logWarn, safeProcess, logFile, runWithCorrelationId, createCorrelationId } = log;
function healthCheck(req, res) {
res.status(200).json({ status: 'alive' });
}
function handleJiraWebhook(routeName, req, res) {
res.status(201).send();
const validation = validateJiraWebhook(req.body);
const correlationMeta = { routeName, issueKey: validation.issueKey || req.body?.issue?.key };
if (!validation.valid) {
runWithCorrelationId(createCorrelationId(correlationMeta), () => {
logWarn(routeName, `Invalid webhook payload: ${validation.error}`);
});
return;
}
safeProcess(routeName, () => {
logFile(WEBHOOK_LOG_PROVIDERS[routeName] || routeName, req.body);
jiraProcessor.processJiraTicket(req.body);
}, correlationMeta);
}
function registerRoutes(app) {
app.get('/healthCheck', healthCheck);
app.get('/heathCheck', healthCheck);
app.post('/jira-request', (req, res) => handleJiraWebhook('jira-request', req, res));
app.post('/request', (req, res) => handleJiraWebhook('request', req, res));
app.post('/support', (req, res) => {
res.status(201).send();
const validation = validateSupportWebhook(req.body);
const correlationMeta = { routeName: 'support', issueKey: validation.issueKey || req.body?.issue?.key };
if (!validation.valid) {
runWithCorrelationId(createCorrelationId(correlationMeta), () => {
logWarn('support', `Invalid webhook payload: ${validation.error}`);
});
return;
}
safeProcess('support', () => {
logFile('support', req.body);
logger('support', `Received support webhook for ${validation.issueKey}`);
webexService.processHighSeverity(req.body, config.rooms.support);
}, correlationMeta);
});
app.use((err, req, res, next) => {
log.logError('express', `${req.method} ${req.path}`, err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal server error' });
}
});
}
return { registerRoutes };
}

122
services/approvalCards.js Normal file
View file

@ -0,0 +1,122 @@
export function buildChangeApproval(jiraTicket, log) {
return new Promise(function (resolve) {
let cardText = '';
cardText += `[${jiraTicket.issue.key}](https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}) - ${jiraTicket.issue.fields.status.name}\n`;
if (jiraTicket.issue.fields.customfield_10010) {
if (jiraTicket.issue.fields.customfield_10010.errorMessage) {
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.errorMessage}\n`;
} else {
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.requestType.name}\n`;
}
}
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
let detailText = '';
detailText += `**Description:** ${jiraTicket.issue.fields.description}\n`;
detailText += '**Details:**\n';
if (jiraTicket.issue.fields.customfield_10010) { detailText += ` - **Type:** ${jiraTicket.issue.fields.customfield_10010.requestType.name}\n`; }
if (jiraTicket.issue.fields.customfield_10080) { detailText += ` - **Risk:** ${jiraTicket.issue.fields.customfield_10080.value}\n`; }
if (jiraTicket.issue.fields.customfield_10004) { detailText += ` - **Impact:** ${jiraTicket.issue.fields.customfield_10004.value}\n`; }
if (jiraTicket.issue.fields.customfield_10256) { detailText += ` - **Business Impact:** ${jiraTicket.issue.fields.customfield_10256}\n`; }
if (jiraTicket.issue.fields.customfield_10252) { detailText += ` - **Business Justification:** ${jiraTicket.issue.fields.customfield_10252}\n`; }
if (jiraTicket.issue.fields.customfield_10257) {
const changeDate = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleDateString();
const changeTime = new Date(jiraTicket.issue.fields.customfield_10257).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: true });
detailText += `**Implementation Date:** ${changeDate} ${changeTime}\n`;
}
let assignmentText = '';
if (jiraTicket.issue.fields.reporter) { assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`; }
if (jiraTicket.issue.fields.customfield_10302) { assignmentText += `**Implementer:** ${jiraTicket.issue.fields.customfield_10302.displayName}\n`; }
const requestCard = {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.2',
body: [
{ type: 'TextBlock', text: cardText.trim(), wrap: true },
{ type: 'TextBlock', wrap: true, text: detailText.trim(), separator: true },
{ type: 'TextBlock', text: assignmentText.trim(), wrap: true, separator: true },
],
actions: [
{
type: 'Action.OpenUrl',
title: 'Open Request',
url: `https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}`,
},
{
type: 'Action.Submit',
title: 'Remove Card',
style: 'destructive',
data: { cardType: 'requestApproval', action: 'removeCard' },
},
],
};
let requestText = cardText;
requestText += `---\n${detailText}`;
requestText += `---\n${assignmentText}`;
log.logDebug('buildChangeApproval', `Built card for ${jiraTicket.issue.key}`);
resolve({ card: requestCard, message: requestText });
});
}
export function buildRequestApproval(jiraTicket) {
return new Promise(function (resolve) {
let cardText = '';
cardText += `[${jiraTicket.issue.key}](https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}) - ${jiraTicket.issue.fields.status.name}\n`;
if (jiraTicket.issue.fields.customfield_10010) {
if (jiraTicket.issue.fields.customfield_10010.errorMessage) {
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.errorMessage}\n`;
} else {
cardText += `**Request Type:** ${jiraTicket.issue.fields.customfield_10010.requestType.name}\n`;
}
}
cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`;
let detailText = '';
detailText += '**Details:**\n';
if (jiraTicket.issue.fields.customfield_10314) {
detailText += ` - **Person:** ${jiraTicket.issue.fields.customfield_10346}, ${jiraTicket.issue.fields.customfield_10314}`;
if (jiraTicket.issue.fields.customfield_10310) { detailText += ` (${jiraTicket.issue.fields.customfield_10310.value})`; }
detailText += '\n';
}
if (jiraTicket.issue.fields.customfield_10318) { detailText += ` - **Position:** ${jiraTicket.issue.fields.customfield_10318}\n`; }
let assignmentText = '';
assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`;
const requestCard = {
type: 'AdaptiveCard',
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
version: '1.2',
body: [
{ type: 'TextBlock', text: cardText.trim(), wrap: true },
{ type: 'TextBlock', wrap: true, text: detailText.trim(), separator: true },
{ type: 'TextBlock', text: assignmentText.trim(), wrap: true, separator: true },
],
actions: [
{
type: 'Action.OpenUrl',
title: 'Open Request',
url: `https://aeo.atlassian.net/servicedesk/customer/portal/135/${jiraTicket.issue.key}`,
},
{
type: 'Action.Submit',
title: 'Remove Card',
style: 'destructive',
data: { cardType: 'requestApproval', action: 'removeCard' },
},
],
};
let requestText = cardText;
requestText += `---\n${detailText}`;
requestText += `---\n${assignmentText}`;
resolve({ card: requestCard, message: requestText });
});
}

62
services/jira.js Normal file
View file

@ -0,0 +1,62 @@
import { sleep } from '../lib/util.js';
export function createJiraService(jiraClient, log) {
const { logger, logError } = log;
function buildApprovers(issueKey) {
return new Promise(async function (resolve, reject) {
const startTime = new Date().getTime();
logger(`buildApprovers(${issueKey})`, 'Building approval list');
const approvalList = [];
await sleep(10000);
jiraClient.issues.getIssue({ issueIdOrKey: issueKey })
.then(issueApprovers => {
for (const approvals of issueApprovers.fields.customfield_10033) {
for (const approver of approvals.approvers) {
if (approver.approverDecision == 'pending') {
approvalList.push(approver.approver.emailAddress);
}
}
}
logger('buildApprovers', `${issueKey}: Found ${approvalList.length} approvers pending. (${new Date().getTime() - startTime}ms)`);
resolve(approvalList);
})
.catch(error => {
logError('buildApprovers', `${issueKey}: Error looking up approvers`, error);
reject(error);
});
});
}
async function searchUnapprovedRequests() {
try {
const response = await jiraClient.issueSearch.searchForIssuesUsingJqlEnhancedSearchPost({
jql: 'status in (Escalated, "Pending Approval", "Technical Approval", "Business Approval", "Technical / IT Approval")',
maxResults: 200,
fields: ['summary', 'status', 'created', 'reporter', 'updated', 'comment', 'customfield_10032', 'customfield_10033'],
});
logger('searchUnapprovedRequests', `Total matching issues: ${response.issues?.total}`);
logger('searchUnapprovedRequests', `Issues found: ${response.issues?.length || 0}`);
if (response.issues && response.issues.length > 0) {
response.issues.forEach(issue => {
logger('searchUnapprovedRequests', `- ${issue.key}: ${issue.fields.summary || 'No summary'} (Status: ${issue.fields.status?.name || 'Unknown'})`);
});
} else {
logger('searchUnapprovedRequests', 'No issues found matching the JQL.');
}
return response.issues || [];
} catch (error) {
logError('searchUnapprovedRequests', 'Jira search failed', error);
return [];
}
}
return {
buildApprovers,
searchUnapprovedRequests,
};
}

21
services/jiraClient.js Normal file
View file

@ -0,0 +1,21 @@
import { Version3Client } from 'jira.js';
export function createJiraClient(config) {
const jira = config.jiraCloud;
const clientConfig = { host: jira.host };
if (jira.authType === 'bearer') {
clientConfig.authentication = {
oauth2: { accessToken: jira.token },
};
} else {
clientConfig.authentication = {
basic: {
email: jira.email,
apiToken: jira.token,
},
};
}
return new Version3Client(clientConfig);
}

84
services/jiraProcessor.js Normal file
View file

@ -0,0 +1,84 @@
import { buildChangeApproval, buildRequestApproval } from './approvalCards.js';
export function createJiraProcessor({ config, requests, jiraService, webexService, log }) {
const { logger, logWarn, logError } = log;
const approvalRoomId = config.rooms.approval;
function handleApprovalNotifications(issueKey, jiraTicket, buildApprovalFn) {
buildApprovalFn(jiraTicket, log)
.then(approvalCard => jiraService.buildApprovers(issueKey).then(approvers => ({ approvalCard, approvers })))
.then(({ approvalCard, approvers }) => {
const cardPayload = {
text: approvalCard.message,
attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: approvalCard.card }],
};
for (const approver of approvers) {
logger('processJiraTicket', `Notifying ${issueKey} --> ${approver}`);
webexService.sendTicketCard(issueKey, { toPersonEmail: approver, ...cardPayload });
}
if (approvers.length > 0) {
webexService.sendTicketCard(issueKey, { roomId: approvalRoomId, ...cardPayload });
}
})
.catch(error => logError('processJiraTicket', `Failed approval notifications for ${issueKey}`, error));
}
function processJiraTicket(jiraTicket) {
if (!jiraTicket?.issue?.key) {
logError('processJiraTicket', 'Missing issue key in webhook payload');
return;
}
const issueKey = jiraTicket.issue.key;
let requestType;
let transitionId;
logger('processJiraTicket', `Processing ${issueKey}.`);
if (jiraTicket.transition) {
logger('processJiraTicket', `${issueKey} Transition: ${jiraTicket.transition.transitionId}.`);
const requestTypeField = jiraTicket.issue.fields?.customfield_10010;
if (requestTypeField?.requestType?.id) {
requestType = requestTypeField.requestType.id;
transitionId = jiraTicket.transition.transitionId;
} else {
logWarn('processJiraTicket', `${issueKey} - missing requestType: ${JSON.stringify(requestTypeField)}`);
}
}
if (!issueKey || !requestType || !requests[requestType]) {
return;
}
logger('processJiraTicket', `${issueKey} RequestType: ${requestType} Requests?: ${JSON.stringify(requests[requestType])}`);
if (requests[requestType].transitions[transitionId]) {
if (jiraTicket.issue.fields.project.key == 'CHANGE') {
logger('processJiraTicket', `${issueKey} is a CHANGE.`);
handleApprovalNotifications(issueKey, jiraTicket, buildChangeApproval);
} else if (jiraTicket.issue.fields.project.key == 'REQUEST') {
logger('processJiraTicket', `${issueKey} is a REQUEST.`);
handleApprovalNotifications(issueKey, jiraTicket, buildRequestApproval);
}
return;
}
if (!jiraTicket.changelog) {
return;
}
const approvalStatuses = ['Pending Approval', 'Emergency Approval', 'Waiting for approval'];
for (const change of jiraTicket.changelog.items) {
if (change.field != 'Approvers' || !approvalStatuses.includes(jiraTicket.issue.fields.status.name)) {
continue;
}
if (jiraTicket.issue.fields.project.key == 'CHANGE') {
handleApprovalNotifications(issueKey, jiraTicket, buildChangeApproval);
} else {
handleApprovalNotifications(issueKey, jiraTicket, buildRequestApproval);
}
}
}
return { processJiraTicket };
}

121
services/webex.js Normal file
View file

@ -0,0 +1,121 @@
import fetch from 'node-fetch';
export function createWebexService(config, log) {
const { logger, logError } = log;
function handleWebexResponse(context, issueKey, response, responseText) {
if (!response.ok) {
logError(context, `Webex API error for ${issueKey} (HTTP ${response.status})`, responseText);
return false;
}
logger(context, `Successfully sent notification for ${issueKey}`);
return true;
}
function sendTicketCard(issueKey, body) {
const requestOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${config.webex.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
redirect: 'follow',
};
fetch('https://webexapis.com/v1/messages', requestOptions)
.then(response => response.text().then(text => ({ response, text })))
.then(({ response, text }) => handleWebexResponse('sendTicketCard', issueKey, response, text))
.catch(error => logError('sendTicketCard', `Network error sending to Webex for ${issueKey}`, error));
}
function sendWebexMessage(body) {
return new Promise(function (resolve, reject) {
const requestOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${config.webex.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
redirect: 'follow',
};
fetch('https://webexapis.com/v1/messages', requestOptions)
.then(response => response.text().then(text => ({ response, text })))
.then(({ response, text }) => {
if (!response.ok) {
return reject(new Error(`Webex API error (HTTP ${response.status}): ${text}`));
}
try {
resolve(JSON.parse(text));
} catch {
reject(new Error(`Invalid JSON response from Webex: ${text}`));
}
})
.catch(error => reject(error));
});
}
function sendHighPriorityMessage(body) {
const requestOptions = {
method: 'POST',
headers: {
Authorization: `Bearer ${config.jiraNotifier.token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
redirect: 'follow',
};
fetch('https://webexapis.com/v1/messages', requestOptions)
.then(response => response.text().then(text => ({ response, text })))
.then(({ response, text }) => handleWebexResponse('sendHighPriorityMessage', body.roomId || 'unknown', response, text))
.catch(error => logError('sendHighPriorityMessage', 'Network error sending to Webex', error));
}
function processHighSeverity(jiraTicket, roomId) {
const jiraKey = jiraTicket.issue.key;
if (jiraTicket.webhookEvent == 'comment_created' && jiraTicket.comment) {
const action = jiraTicket.comment.created == jiraTicket.comment.updated ? 'added a comment' : 'edited a comment';
const userName = jiraTicket.comment.updateAuthor.displayName;
const projectKey = jiraTicket.issue.fields.project.name;
const comment = jiraTicket.comment.body;
sendHighPriorityMessage({
markdown: `${userName} ${action} on the [${jiraKey}](https://aeo.atlassian.net/browse/${jiraKey}) issue in the ${projectKey} project to read as follows:\n${comment}`,
roomId,
});
}
if (jiraTicket.webhookEvent == 'jira:issue_updated' && jiraTicket.changelog) {
const userName = jiraTicket.user.displayName;
const statuses = ['status', 'Severity', 'priority', 'Severity (migrated)'];
let text = '';
let addDescription = 0;
for (const change of jiraTicket.changelog.items) {
if (statuses.includes(change.field)) {
text += `${userName} changed ${change.field} from ${change.fromString} to ${change.toString} for issue [${jiraKey}](https://aeo.atlassian.net/browse/${jiraKey}).\n`;
}
if (change.field == 'Severity' || change.field == 'priority') {
addDescription = 1;
}
}
if (addDescription > 0) {
text += `Description / Details: ${jiraTicket.issue.fields.description}\n`;
}
log.logDebug('processHighSeverity', `Sending high-priority update for ${jiraKey}`);
sendHighPriorityMessage({ markdown: text, roomId });
}
}
return {
sendTicketCard,
sendWebexMessage,
sendHighPriorityMessage,
processHighSeverity,
};
}