diff --git a/.gitignore b/.gitignore index ef02aaa..2a7fde3 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ logs/ config/config.json config/config.local.json config/webex-oauth.json +config/requestTypeFields.discovered.json diff --git a/config/requestTypeFields.json b/config/requestTypeFields.json new file mode 100644 index 0000000..b8ada40 --- /dev/null +++ b/config/requestTypeFields.json @@ -0,0 +1,296 @@ +{ + "_meta": { + "description": "Per-request-type fields to show on Webex approval notifications. Regenerate raw schemas with: node scripts/discover-request-type-fields.js", + "lastReviewed": "2026-07-28", + "serviceDesks": { + "CHANGE": "135", + "REQUEST": "136" + }, + "notes": [ + "Some REQUEST forms only expose summary via the JSM field API (dynamic/legacy forms). Those types use summaryOnly — do not attach personIdentity unless discovery shows those fields.", + "Request type 382 was retired in Jira; use 194 (Service Request) on desk 136.", + "fieldId 'summary' uses issue.fields.summary. Use path for nested webhook/transition data.", + "format hints: text | option | user | datetime | cmdb | multiselect | textarea" + ] + }, + "common": { + "displayFields": [ + { "path": "transition.transitionName", "label": "Approval step" }, + { "path": "transition.from_status", "label": "From status" }, + { "path": "transition.to_status", "label": "To status" }, + { "path": "user.displayName", "label": "Moved by" }, + { "fieldId": "priority", "label": "Priority", "format": "option" }, + { "fieldId": "reporter", "label": "Reporter", "format": "user" }, + { "fieldId": "assignee", "label": "Assignee", "format": "user" }, + { "fieldId": "created", "label": "Created", "format": "datetime" }, + { "fieldId": "updated", "label": "Updated", "format": "datetime" } + ] + }, + "groups": { + "personIdentity": { + "description": "Shared person block on many REQUEST access forms", + "displayFields": [ + { "fieldId": "customfield_10346", "label": "Last name" }, + { "fieldId": "customfield_10314", "label": "First name" }, + { "fieldId": "customfield_10318", "label": "Position/Title" }, + { "fieldId": "customfield_10310", "label": "Employment type", "format": "option" }, + { "fieldId": "customfield_10419", "label": "Network user name" }, + { "fieldId": "customfield_10316", "label": "Employee number" }, + { "fieldId": "customfield_10353", "label": "Department", "format": "cmdb" } + ] + }, + "serverInfrastructure": { + "description": "Server build / decommission shared fields", + "displayFields": [ + { "fieldId": "customfield_10319", "label": "Server type", "format": "option" }, + { "fieldId": "customfield_10336", "label": "Environment(s)", "format": "multiselect" }, + { "fieldId": "customfield_10397", "label": "Server hardware type", "format": "option" }, + { "fieldId": "customfield_10334", "label": "Server location & region", "format": "cascading" }, + { "fieldId": "customfield_10418", "label": "Application list", "format": "cmdb" }, + { "fieldId": "customfield_10320", "label": "Server name", "format": "cmdb" } + ] + }, + "changeManagement": { + "description": "Standard CHANGE approval context", + "displayFields": [ + { "fieldId": "customfield_10287", "label": "Change category", "format": "option" }, + { "fieldId": "customfield_10259", "label": "Impact level", "format": "option" }, + { "fieldId": "customfield_10257", "label": "Implementation date", "format": "datetime" }, + { "fieldId": "customfield_10252", "label": "Business justification", "format": "textarea" }, + { "fieldId": "customfield_10256", "label": "Business impact / risk", "format": "textarea" }, + { "fieldId": "customfield_10253", "label": "Testing completed", "format": "textarea" }, + { "fieldId": "customfield_10292", "label": "Rollback plan", "format": "textarea" }, + { "fieldId": "customfield_10242", "label": "Related systems/CIs", "format": "cmdb" }, + { "fieldId": "customfield_10302", "label": "Implementer", "format": "user" }, + { "fieldId": "customfield_10241", "label": "Related Jira key" } + ] + }, + "summaryOnly": { + "description": "Forms where JSM API only exposes summary — show summary + description", + "displayFields": [ + { "fieldId": "summary", "label": "Summary" }, + { "fieldId": "description", "label": "Description", "format": "textarea" } + ] + } + }, + "requestTypes": { + "177": { + "name": "Request a Change", + "project": "CHANGE", + "serviceDeskId": "135", + "includeCommon": true, + "groups": ["changeManagement"], + "displayFields": [ + { "fieldId": "customfield_10250", "label": "Business partner(s)", "format": "users" } + ] + }, + "179": { + "name": "Customer Technology Production Change", + "project": "CHANGE", + "serviceDeskId": "135", + "includeCommon": true, + "displayFields": [ + { "fieldId": "customfield_10257", "label": "Production deploy date", "format": "datetime" }, + { "fieldId": "customfield_10252", "label": "Business justification", "format": "textarea" }, + { "fieldId": "customfield_10256", "label": "Impacted services", "format": "textarea" }, + { "fieldId": "customfield_10253", "label": "Testing completed", "format": "textarea" }, + { "fieldId": "customfield_10292", "label": "Rollback plan", "format": "textarea" }, + { "fieldId": "customfield_10242", "label": "Configuration items", "format": "cmdb" }, + { "fieldId": "customfield_10250", "label": "Approvers", "format": "users" }, + { "fieldId": "customfield_10241", "label": "Related Jira key(s)" } + ] + }, + "183": { + "name": "Firewall Access", + "project": "CHANGE", + "serviceDeskId": "135", + "includeCommon": true, + "displayFields": [ + { "fieldId": "customfield_10309", "label": "Firewall request details", "format": "textarea" }, + { "fieldId": "customfield_10252", "label": "End result / justification", "format": "textarea" }, + { "fieldId": "customfield_10242", "label": "Application(s)", "format": "cmdb" }, + { "fieldId": "customfield_10302", "label": "Implementer", "format": "user" }, + { "fieldId": "customfield_10241", "label": "Related ticket" } + ] + }, + "186": { + "name": "Request Store Fixtures", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "displayFields": [ + { "fieldId": "customfield_10261", "label": "Store number", "format": "cmdb" }, + { "fieldId": "customfield_10689", "label": "Items requested", "format": "textarea" } + ] + }, + "187": { + "name": "New Server Build Request", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["personIdentity", "serverInfrastructure"], + "displayFields": [ + { "fieldId": "customfield_10371", "label": "Active Directory group" }, + { "fieldId": "customfield_10244", "label": "Technical owner", "format": "users" } + ] + }, + "188": { + "name": "DI Access", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"], + "notes": "JSM field API returns summary-only form; summary field is labeled 'Brief Summary for DI Request'" + }, + "189": { + "name": "IBMi Access", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["personIdentity"], + "displayFields": [ + { "fieldId": "customfield_10352", "label": "IBMi applications", "format": "cmdb" }, + { "fieldId": "customfield_10365", "label": "Model after user", "format": "user" }, + { "fieldId": "customfield_10326", "label": "IBMi role", "format": "option" }, + { "fieldId": "customfield_10434", "label": "Environment", "format": "option" }, + { "fieldId": "customfield_10321", "label": "DC location", "format": "multiselect" } + ] + }, + "190": { + "name": "Server Access", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"], + "notes": "Legacy form — enrich via getIssue if more fields needed" + }, + "191": { + "name": "Desktop Requests", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"], + "notes": "Summary field labeled 'Brief Description' on form" + }, + "192": { + "name": "Contractor Conversion", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"] + }, + "193": { + "name": "Onboard New Hire / Modify Existing User", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"], + "notes": "JSM field API returns summary-only form (portal label: Onboard New Hire)" + }, + "197": { + "name": "Control M Job Request", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"], + "displayFields": [ + { "fieldId": "customfield_10255", "label": "Control-M job name" } + ], + "notes": "Summary field is 'Brief Description of Control M Request' — job name and details are typically entered in summary text. customfield_10255 is shown when present on the issue." + }, + "199": { + "name": "Contractor Extension", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "displayFields": [ + { "fieldId": "customfield_10387", "label": "Contractor name(s)", "format": "users" }, + { "fieldId": "customfield_10353", "label": "Contractor department", "format": "cmdb" }, + { "fieldId": "customfield_10299", "label": "Contractor manager", "format": "user" }, + { "fieldId": "customfield_10310", "label": "Employment type", "format": "option" }, + { "fieldId": "customfield_10366", "label": "Vendor name" }, + { "fieldId": "customfield_10410", "label": "Applications supported" } + ] + }, + "200": { + "name": "Corporate Credit Card Request", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "displayFields": [ + { "fieldId": "customfield_11367", "label": "Applicant", "format": "cmdb" }, + { "fieldId": "customfield_10401", "label": "Monthly travel frequency" }, + { "fieldId": "customfield_10373", "label": "International travel", "format": "option" }, + { "fieldId": "customfield_10354", "label": "Purchase types", "format": "textarea" } + ] + }, + "202": { + "name": "Enterprise Access", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"] + }, + "203": { + "name": "Clarity Onboarding", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"] + }, + "204": { + "name": "Server Decommission Request", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["serverInfrastructure"], + "displayFields": [ + { "fieldId": "customfield_10353", "label": "Reporter department", "format": "cmdb" }, + { "fieldId": "customfield_10328", "label": "Server not found (manual entry)" } + ] + }, + "209": { + "name": "Application Access", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["personIdentity"], + "displayFields": [ + { "fieldId": "customfield_10414", "label": "User location", "format": "cmdb" }, + { "fieldId": "customfield_10323", "label": "Date of hire", "format": "date" }, + { "fieldId": "customfield_10365", "label": "Model after associate", "format": "user" }, + { "fieldId": "customfield_10333", "label": "Atlassian Suite", "format": "cmdb" }, + { "fieldId": "customfield_10379", "label": "Data Insights apps", "format": "cmdb" }, + { "fieldId": "customfield_10331", "label": "Digital", "format": "cmdb" }, + { "fieldId": "customfield_10332", "label": "Finance", "format": "cmdb" }, + { "fieldId": "customfield_10427", "label": "IT", "format": "cmdb" }, + { "fieldId": "customfield_10393", "label": "HR", "format": "cmdb" }, + { "fieldId": "customfield_10404", "label": "Supply Chain", "format": "cmdb" } + ], + "notes": "High-volume type. Only include CMDB groups that are populated on the issue to avoid card clutter." + }, + "211": { + "name": "Database Access", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "groups": ["summaryOnly"] + }, + "194": { + "name": "Service Request", + "project": "REQUEST", + "serviceDeskId": "136", + "includeCommon": true, + "displayFields": [ + { "fieldId": "customfield_10353", "label": "Department", "format": "cmdb" }, + { "fieldId": "customfield_10414", "label": "Location", "format": "cmdb" }, + { "fieldId": "customfield_10335", "label": "Reason for request", "format": "textarea" }, + { "fieldId": "customfield_10307", "label": "Implementation date", "format": "datetime" }, + { "fieldId": "customfield_10337", "label": "Reason for implementation date", "format": "textarea" }, + { "fieldId": "summary", "label": "Summary" }, + { "fieldId": "description", "label": "Detailed description", "format": "textarea" } + ], + "notes": "Replaces retired request type 382. Transitions 81/101 inherited from 382 config — verify on first webhook." + } + } +} diff --git a/config/requests.json b/config/requests.json index e830803..cd0743f 100644 --- a/config/requests.json +++ b/config/requests.json @@ -87,7 +87,7 @@ } } }, - "382": { + "194": { "name": "Service Request", "transitions": { "81": { diff --git a/docker-compose.yml b/docker-compose.yml index 487f19e..b6bc433 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - ./config/config.json:/usr/src/app/config/config.json:ro - ./config/requests.json:/usr/src/app/config/requests.json:ro - ./config/webexRooms.json:/usr/src/app/config/webexRooms.json:ro + - ./config/requestTypeFields.json:/usr/src/app/config/requestTypeFields.json:ro - ./config/webex-oauth.json:/usr/src/app/config/webex-oauth.json restart: unless-stopped healthcheck: diff --git a/lib/requestTypeFields.js b/lib/requestTypeFields.js new file mode 100644 index 0000000..694af13 --- /dev/null +++ b/lib/requestTypeFields.js @@ -0,0 +1,129 @@ +import fs from 'fs'; + +const SKIP_FIELD_IDS = new Set(['attachment']); + +export function loadRequestTypeFields() { + const path = './config/requestTypeFields.json'; + if (!fs.existsSync(path)) { + return { common: { displayFields: [] }, groups: {}, requestTypes: {} }; + } + return JSON.parse(fs.readFileSync(path, 'utf8')); +} + +function resolveFieldEntry(entry, groups) { + if (typeof entry === 'string' && groups[entry]) { + return groups[entry].displayFields || []; + } + return [entry]; +} + +export function getDisplayFieldsForRequestType(requestTypeId, fieldMap = loadRequestTypeFields()) { + const key = String(requestTypeId); + const typeConfig = fieldMap.requestTypes?.[key]; + if (!typeConfig) { + return fieldMap.common?.displayFields || []; + } + + const groups = fieldMap.groups || {}; + const resolved = []; + + if (typeConfig.includeCommon !== false) { + resolved.push(...(fieldMap.common?.displayFields || [])); + } + + for (const groupRef of typeConfig.groups || []) { + const groupFields = groups[groupRef]?.displayFields || []; + resolved.push(...groupFields); + } + + for (const field of typeConfig.displayFields || []) { + resolved.push(...resolveFieldEntry(field, groups)); + } + + const seen = new Set(); + return resolved.filter(field => { + const dedupeKey = field.path || field.fieldId; + if (!dedupeKey || seen.has(dedupeKey) || SKIP_FIELD_IDS.has(field.fieldId)) { + return false; + } + seen.add(dedupeKey); + return true; + }); +} + +export function formatFieldValue(value, format) { + if (value == null || value === '') { + return null; + } + + switch (format) { + case 'option': + return value.value ?? value.name ?? String(value); + case 'user': + return value.displayName ?? value.emailAddress ?? String(value); + case 'users': + if (!Array.isArray(value)) { + return formatFieldValue(value, 'user'); + } + return value.map(item => formatFieldValue(item, 'user')).filter(Boolean).join(', '); + case 'datetime': + case 'date': + return new Date(value).toLocaleString(); + case 'cmdb': + if (Array.isArray(value)) { + return value.map(item => item.label || item.name || item.objectKey || item.value).filter(Boolean).join(', '); + } + return value.label || value.name || value.objectKey || String(value); + case 'multiselect': + case 'multicheckboxes': + if (!Array.isArray(value)) { + return String(value); + } + return value.map(item => item.value || item.label || String(item)).join(', '); + case 'cascading': + if (value.child) { + return `${value.value} → ${value.child.value}`; + } + return value.value ?? String(value); + case 'textarea': + case 'text': + default: + if (typeof value === 'object') { + return value.displayName || value.value || value.name || JSON.stringify(value); + } + return String(value); + } +} + +export function getFieldValueFromTicket(jiraTicket, fieldDef) { + if (fieldDef.path) { + return fieldDef.path.split('.').reduce((current, part) => current?.[part], jiraTicket); + } + + const fieldId = fieldDef.fieldId; + if (!fieldId) { + return null; + } + + if (fieldId === 'summary' || fieldId === 'description' || fieldId === 'priority' || fieldId === 'created' || fieldId === 'updated') { + const fields = jiraTicket.issue?.fields || {}; + return fields[fieldId]; + } + + return jiraTicket.issue?.fields?.[fieldId] ?? null; +} + +export function buildDetailLinesForRequestType(jiraTicket, requestTypeId, fieldMap = loadRequestTypeFields()) { + const fields = getDisplayFieldsForRequestType(requestTypeId, fieldMap); + const lines = []; + + for (const fieldDef of fields) { + const rawValue = getFieldValueFromTicket(jiraTicket, fieldDef); + const formatted = formatFieldValue(rawValue, fieldDef.format); + if (formatted) { + lines.push({ label: fieldDef.label, value: formatted }); + } + } + + return lines; +} diff --git a/scripts/discover-request-type-fields.js b/scripts/discover-request-type-fields.js new file mode 100644 index 0000000..f495631 --- /dev/null +++ b/scripts/discover-request-type-fields.js @@ -0,0 +1,88 @@ +#!/usr/bin/env node +// Dump JSM request-type field definitions for approval card mapping. +// +// Usage: +// node scripts/discover-request-type-fields.js +// Writes config/requestTypeFields.discovered.json + +import dotenv from 'dotenv'; +import fetch from 'node-fetch'; +import fs from 'fs'; + +dotenv.config(); + +const cloudId = process.env.JIRA_CLOUD_ID?.trim(); +const host = cloudId + ? `https://api.atlassian.com/ex/jira/${cloudId}` + : (process.env.JIRA_BASE_URL || 'https://aeo.atlassian.net').replace(/\/$/, ''); +const email = process.env.JIRA_EMAIL; +const token = process.env.JIRA_API_TOKEN; + +if (!email || !token) { + console.error('JIRA_EMAIL and JIRA_API_TOKEN must be set in .env'); + process.exit(1); +} + +const headers = { + Accept: 'application/json', + Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`, +}; + +const deskMap = { + 177: '135', + 179: '135', + 183: '135', +}; +const defaultDesk = '136'; + +const requests = JSON.parse(fs.readFileSync('./config/requests.json', 'utf8')); + +function summarizeField(field) { + return { + fieldId: field.fieldId, + name: field.name, + required: field.required, + type: field.jiraSchema?.type, + custom: field.jiraSchema?.custom, + }; +} + +const output = { + _meta: { + generatedAt: new Date().toISOString(), + serviceDesks: { CHANGE: '135', REQUEST: '136' }, + source: 'GET /rest/servicedeskapi/servicedesk/{deskId}/requesttype/{id}/field', + }, + requestTypes: {}, +}; + +for (const [id, cfg] of Object.entries(requests)) { + const deskId = deskMap[id] || defaultDesk; + const url = `${host}/rest/servicedeskapi/servicedesk/${deskId}/requesttype/${id}/field`; + const response = await fetch(url, { headers }); + const data = await response.json(); + + if (!response.ok) { + output.requestTypes[id] = { + name: cfg.name, + serviceDeskId: deskId, + error: data.errorMessage || String(response.status), + }; + continue; + } + + output.requestTypes[id] = { + name: cfg.name, + serviceDeskId: deskId, + fields: (data.requestTypeFields || []).map(summarizeField), + }; +} + +const outputPath = './config/requestTypeFields.discovered.json'; +fs.writeFileSync(outputPath, JSON.stringify(output, null, 2)); +console.log(`Wrote ${outputPath}`); + +for (const [id, rt] of Object.entries(output.requestTypes)) { + const count = rt.fields?.length ?? 0; + console.log(`${id} ${rt.name}: ${rt.error || `${count} fields`}`); +} diff --git a/services/approvalCards.js b/services/approvalCards.js index 78302f8..43b47b9 100644 --- a/services/approvalCards.js +++ b/services/approvalCards.js @@ -1,3 +1,58 @@ +import { buildDetailLinesForRequestType } from '../lib/requestTypeFields.js'; + +function appendDetailLines(detailText, lines) { + for (const line of lines) { + detailText += ` - **${line.label}:** ${line.value}\n`; + } + return detailText; +} + +function getRequestTypeId(jiraTicket) { + return jiraTicket.issue.fields?.customfield_10010?.requestType?.id; +} + +function buildCardPayload({ jiraTicket, cardText, detailText, assignmentText = '' }) { + 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 }, + ], + 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' }, + }, + ], + }; + + if (assignmentText.trim()) { + requestCard.body.push({ + type: 'TextBlock', + text: assignmentText.trim(), + wrap: true, + separator: true, + }); + } + + let requestText = cardText; + requestText += `---\n${detailText}`; + if (assignmentText.trim()) { + requestText += `---\n${assignmentText}`; + } + + return { card: requestCard, message: requestText }; +} + export function buildChangeApproval(jiraTicket, log) { return new Promise(function (resolve) { let cardText = ''; @@ -12,54 +67,28 @@ export function buildChangeApproval(jiraTicket, log) { } 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 detailText = '**Details:**\n'; + if (jiraTicket.issue.fields.description) { + detailText += `**Description:** ${jiraTicket.issue.fields.description}\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}`; + const requestTypeId = getRequestTypeId(jiraTicket); + if (requestTypeId) { + detailText = appendDetailLines(detailText, buildDetailLinesForRequestType(jiraTicket, requestTypeId)); + } else { + 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`; + } + } log.logDebug('buildChangeApproval', `Built card for ${jiraTicket.issue.key}`); - resolve({ card: requestCard, message: requestText }); + resolve(buildCardPayload({ jiraTicket, cardText, detailText })); }); } @@ -77,46 +106,22 @@ export function buildRequestApproval(jiraTicket) { } cardText += `**Summary:** ${jiraTicket.issue.fields.summary}\n`; - let detailText = ''; - detailText += '**Details:**\n'; - if (jiraTicket.issue.fields.customfield_10314) { + let detailText = '**Details:**\n'; + const requestTypeId = getRequestTypeId(jiraTicket); + if (requestTypeId) { + detailText = appendDetailLines(detailText, buildDetailLinesForRequestType(jiraTicket, requestTypeId)); + } else 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`; } } - if (jiraTicket.issue.fields.customfield_10318) { detailText += ` - **Position:** ${jiraTicket.issue.fields.customfield_10318}\n`; } let assignmentText = ''; - assignmentText += `**Reporter:** ${jiraTicket.issue.fields.reporter.displayName}\n`; + if (jiraTicket.issue.fields.reporter) { + 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 }); + resolve(buildCardPayload({ jiraTicket, cardText, detailText, assignmentText })); }); } diff --git a/test/requestTypeFields.test.js b/test/requestTypeFields.test.js new file mode 100644 index 0000000..a589222 --- /dev/null +++ b/test/requestTypeFields.test.js @@ -0,0 +1,86 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildDetailLinesForRequestType, + formatFieldValue, + getDisplayFieldsForRequestType, +} from '../lib/requestTypeFields.js'; + +const sampleTicket = { + transition: { + transitionName: 'Manager Approval', + from_status: 'New Request', + to_status: 'Pending Approval', + }, + user: { displayName: 'Automation for Jira' }, + issue: { + fields: { + priority: { value: 'Medium' }, + reporter: { displayName: 'Savannah Davis' }, + customfield_10346: 'Davis', + customfield_10314: 'Savannah', + customfield_10318: 'Analyst', + customfield_10310: { value: 'Full Time' }, + customfield_10353: [{ label: 'IT Operations' }], + summary: 'Application Access for Savannah Davis', + }, + }, +}; + +describe('getDisplayFieldsForRequestType', () => { + it('returns fields for Application Access including common and groups', () => { + const fields = getDisplayFieldsForRequestType('209'); + const labels = fields.map(f => f.label); + assert.ok(labels.includes('Reporter')); + assert.ok(labels.includes('First name')); + assert.ok(labels.includes('IT')); + }); + + it('returns change management fields for request type 177', () => { + const fields = getDisplayFieldsForRequestType('177'); + const labels = fields.map(f => f.label); + assert.ok(labels.includes('Rollback plan')); + assert.ok(labels.includes('Implementation date')); + }); + + it('uses summaryOnly for legacy single-field forms', () => { + const fields = getDisplayFieldsForRequestType('211'); + const labels = fields.map(f => f.label); + assert.ok(labels.includes('Summary')); + assert.ok(!labels.includes('First name')); + }); + + it('returns Service Request fields for request type 194', () => { + const fields = getDisplayFieldsForRequestType('194'); + const labels = fields.map(f => f.label); + assert.ok(labels.includes('Department')); + assert.ok(labels.includes('Reason for request')); + assert.ok(!labels.includes('First name')); + }); +}); + +describe('formatFieldValue', () => { + it('formats option and user values', () => { + assert.equal(formatFieldValue({ value: 'High' }, 'option'), 'High'); + assert.equal(formatFieldValue({ displayName: 'Jane Doe' }, 'user'), 'Jane Doe'); + }); + + it('formats cmdb arrays', () => { + assert.equal( + formatFieldValue([{ label: 'Store 123' }, { label: 'Store 456' }], 'cmdb'), + 'Store 123, Store 456' + ); + }); +}); + +describe('buildDetailLinesForRequestType', () => { + it('builds populated lines from webhook payload', () => { + const lines = buildDetailLinesForRequestType(sampleTicket, '209'); + const byLabel = Object.fromEntries(lines.map(line => [line.label, line.value])); + + assert.equal(byLabel['Approval step'], 'Manager Approval'); + assert.equal(byLabel['First name'], 'Savannah'); + assert.equal(byLabel['Position/Title'], 'Analyst'); + assert.equal(byLabel.Department, 'IT Operations'); + }); +});