From b8f2eab4abf3f7ea79d2130157778508bff7878b Mon Sep 17 00:00:00 2001 From: jmcqueen Date: Wed, 1 Jul 2026 16:36:21 -0400 Subject: [PATCH] Refactor #6: split jiraService.js into services/jira/{client,issues,comments,attachments,jsmRequests,assets} MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure move + one-way rewire, no logic changes. The old 1467-line monolithic services/jiraService.js becomes a thin barrel that re-exports the same public surface so both current consumers keep working unchanged: - src/routes/wxccRoutes.js: `import * as jiraService` - src/services/healthService.js: `import { jiraClient }` New module layout (deps flow one-way, no cycles): client.js — jiraClient, downloadClient, plainTextToAdf (foundational) issues.js — fetch/search/status/update/transitions/close comments.js — fetchPublicComments, addComment, postWebexSummaryComment attachments.js — attachFileToJira, attachReadableTranscript assets.js — AQL, resolveStoreAssetReference, probeAssetsForStore jsmRequests.js — REQUEST_TYPE_MAP, createSSRequest, subtype helpers Verified: barrel re-exports every original name (23 named + default with same 17 members), REQUEST_TYPE_MAP still has 14 entries, jiraClient still instantiates against the configured baseURL, and both consumers import without errors under the real ES module loader. New code should import from services/jira/* directly; the barrel is only for backward compatibility with existing callers. Co-authored-by: Cursor --- src/services/jira/assets.js | 650 +++++++++++++ src/services/jira/attachments.js | 154 +++ src/services/jira/client.js | 84 ++ src/services/jira/comments.js | 121 +++ src/services/jira/issues.js | 290 ++++++ src/services/jira/jsmRequests.js | 149 +++ src/services/jiraService.js | 1531 ++---------------------------- 7 files changed, 1531 insertions(+), 1448 deletions(-) create mode 100644 src/services/jira/assets.js create mode 100644 src/services/jira/attachments.js create mode 100644 src/services/jira/client.js create mode 100644 src/services/jira/comments.js create mode 100644 src/services/jira/issues.js create mode 100644 src/services/jira/jsmRequests.js diff --git a/src/services/jira/assets.js b/src/services/jira/assets.js new file mode 100644 index 0000000..1217874 --- /dev/null +++ b/src/services/jira/assets.js @@ -0,0 +1,650 @@ +// Jira Assets (formerly Insight / CMDB) integration. +// +// Store objects are "service objects" and therefore live behind the +// workspace-scoped API: +// POST https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1/object/aql +// +// This module contains: +// - Low-level helpers (workspace discovery, AQL POST, Assets GET) that +// never throw on non-2xx so callers can inspect what happened. +// - resolveStoreAssetReference: production path — store number → object ref. +// - probeAssetsForStore: dev-only diagnostic that runs many AQL variants +// plus schema/object-type introspection and returns a plain-English +// diagnosis of what's misconfigured. +import axios from 'axios'; +import config from '../../config/index.js'; +import logger from '../../utilities/logger.js'; +import { jiraClient } from './client.js'; + +/** + * Resolve the Assets workspace id. Prefers the explicit env var; otherwise + * discovers via /rest/servicedeskapi/assets/workspace. + */ +async function getAssetsWorkspaceId() { + if (config.jira.assetsWorkspaceId) { + return config.jira.assetsWorkspaceId; + } + + const list = await listAssetsWorkspacesRaw(); + const first = list.workspaces[0]; + if (first?.workspaceId) { + logger.info(`Discovered Assets workspaceId via /rest/servicedeskapi/assets/workspace: ${first.workspaceId}`); + if (list.workspaces.length > 1) { + logger.warn(`Multiple Assets workspaces are visible to this account (${list.workspaces.length}); using the first. Set JIRA_ASSETS_WORKSPACE_ID explicitly to disambiguate.`, { + workspaces: list.workspaces.map(w => w.workspaceId) + }); + } + return first.workspaceId; + } + + throw new Error('JIRA_ASSETS_WORKSPACE_ID is required for Assets object lookup (or ensure /rest/servicedeskapi/assets/workspace is accessible).'); +} + +/** + * Return the full list of Assets workspaces the current account can see, plus + * the raw payload for diagnostics. Never throws. + */ +async function listAssetsWorkspacesRaw() { + try { + const resp = await jiraClient.get('/rest/servicedeskapi/assets/workspace'); + const data = resp.data; + + let entries = []; + if (Array.isArray(data)) entries = data; + else if (Array.isArray(data?.values)) entries = data.values; + else if (Array.isArray(data?.workspaces)) entries = data.workspaces; + else if (data && typeof data === 'object') entries = [data]; + + const workspaces = entries + .map(e => ({ workspaceId: e.workspaceId || e.id || e.key || e.workspaceID || null })) + .filter(w => w.workspaceId); + + return { httpStatus: resp.status, workspaces, raw: data }; + } catch (e) { + return { + httpStatus: e.response?.status || 'network', + workspaces: [], + raw: e.response?.data || null, + error: e.message + }; + } +} + +/** + * Filter out response headers that would leak scope/tenant/session identifiers + * or noise (cookies, tracing tokens, CORS bookkeeping). We keep just the + * Atlassian informational headers useful for debugging (rate-limit, tracing, + * deprecation, request id). + */ +function pickInterestingHeaders(headers = {}) { + const wanted = new Set([ + 'content-type', 'content-length', + 'x-request-id', 'x-arequestid', 'x-arequest-id', + 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', + 'x-atlassian-request-id', 'x-atlassian-trace-id', + 'atl-traceid', 'atl-request-id', + 'x-atlassian-server-status', 'x-atlassian-cursor', + 'x-content-type-options', 'x-frame-options', + 'deprecation', 'sunset', 'warning', 'retry-after' + ]); + const out = {}; + for (const [k, v] of Object.entries(headers)) { + if (wanted.has(k.toLowerCase())) out[k] = v; + } + return out; +} + +/** + * Low-level AQL POST helper. Never throws on non-2xx. + * Returns { status, statusText, data, headers, requestUrl, requestBody, workspaceId, error }. + * Uses bare axios (not jiraClient) because the URL is api.atlassian.com, not + * the Jira baseURL. Reuses jiraClient's Authorization header. + */ +async function runAssetsAql(qlQuery, { resultPerPage = 5, includeAttributes = true, extraBody = {} } = {}) { + const workspaceId = await getAssetsWorkspaceId(); + const aqlUrl = `https://api.atlassian.com/jsm/assets/workspace/${workspaceId}/v1/object/aql`; + + const authHeader = jiraClient.defaults.headers.Authorization + || jiraClient.defaults.headers.common?.Authorization; + + const body = { qlQuery, resultPerPage, includeAttributes, ...extraBody }; + + logger.debug('Assets AQL request', { aqlUrl, qlQuery, resultPerPage }); + + try { + const resp = await axios.post(aqlUrl, body, { + headers: { + 'Authorization': authHeader, + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + timeout: 15000, + validateStatus: () => true + }); + return { + status: resp.status, + statusText: resp.statusText, + data: resp.data ?? null, + headers: pickInterestingHeaders(resp.headers || {}), + requestUrl: aqlUrl, + requestBody: body, + workspaceId + }; + } catch (err) { + return { + status: err.response?.status || 'network', + statusText: err.response?.statusText || err.code || 'error', + data: err.response?.data || null, + headers: pickInterestingHeaders(err.response?.headers || {}), + requestUrl: aqlUrl, + requestBody: body, + workspaceId, + error: err.message + }; + } +} + +/** + * Low-level GET helper against api.atlassian.com Assets endpoints. + * Same shape as runAssetsAql. Use for schema/objecttype introspection. + */ +async function runAssetsGet(path) { + const workspaceId = await getAssetsWorkspaceId(); + const url = `https://api.atlassian.com/jsm/assets/workspace/${workspaceId}/v1${path}`; + + const authHeader = jiraClient.defaults.headers.Authorization + || jiraClient.defaults.headers.common?.Authorization; + + try { + const resp = await axios.get(url, { + headers: { 'Authorization': authHeader, 'Accept': 'application/json' }, + timeout: 15000, + validateStatus: () => true + }); + return { + status: resp.status, + statusText: resp.statusText, + data: resp.data ?? null, + headers: pickInterestingHeaders(resp.headers || {}), + requestUrl: url, + workspaceId + }; + } catch (err) { + return { + status: err.response?.status || 'network', + statusText: err.response?.statusText || err.code || 'error', + data: err.response?.data || null, + headers: pickInterestingHeaders(err.response?.headers || {}), + requestUrl: url, + workspaceId, + error: err.message + }; + } +} + +/** + * List all Assets schemas visible to the current token. Critical diagnostic: + * if this returns zero schemas, the token has no Assets access at all + * (regardless of what workspace id is used). + */ +export async function listAssetsSchemas() { + return runAssetsGet('/objectschema/list'); +} + +/** + * Fetch a single Assets object type (id, name, attributes). If HTTP 200, + * the token can see the type — and the attribute names in the response are + * authoritative for AQL queries. + */ +export async function getAssetsObjectType(objectTypeId) { + const [detail, attributes] = await Promise.all([ + runAssetsGet(`/objecttype/${objectTypeId}`), + runAssetsGet(`/objecttype/${objectTypeId}/attributes`) + ]); + return { detail, attributes }; +} + +/** + * Flatten one Assets AQL "value" (object entry) into a compact shape suitable + * for humans debugging attribute names/values. Different Assets tenants return + * subtly different envelopes (attributes[].objectTypeAttribute vs typeAttribute, + * objectAttributeValues[].value vs displayValue), so we're defensive. + */ +function summarizeAssetsObject(obj) { + if (!obj || typeof obj !== 'object') return null; + + const attributes = Array.isArray(obj.attributes) ? obj.attributes.map(attr => { + const meta = attr.objectTypeAttribute || attr.typeAttribute || {}; + const rawValues = Array.isArray(attr.objectAttributeValues) ? attr.objectAttributeValues : []; + const values = rawValues.map(v => v.displayValue ?? v.value ?? v.searchValue ?? null).filter(v => v !== null); + return { + id: attr.objectTypeAttributeId || meta.id || null, + name: meta.name || null, + values + }; + }) : []; + + return { + id: obj.id || null, + objectKey: obj.objectKey || null, + name: obj.label || obj.name || null, + objectType: obj.objectType?.name || null, + objectTypeId: obj.objectType?.id || null, + attributes + }; +} + +/** + * Resolve a store number (e.g. "00305" or 305) to the Assets object reference + * for the Store custom field in a JSM request: + * customfield_10261: [ { "objectId": "82288" } ] + * Tries multiple AQL variants (attribute name; attribute id if configured). + */ +export async function resolveStoreAssetReference(rawStoreNumber) { + if (!rawStoreNumber) return null; + + const normalized = String(rawStoreNumber).padStart(5, '0'); + const objectTypeId = config.jira.assetsStoreObjectTypeId || '109'; + const attribute = config.jira.assetsStoreNumberAttribute || 'Store Number'; + const attrId = config.jira.assetsStoreNumberAttributeId; + + const queries = [ + `objectTypeId = ${objectTypeId} AND "${attribute}" = "${normalized}"` + ]; + if (attrId) { + queries.push(`objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${normalized}"`); + } + + let lastResult = null; + + for (const qlQuery of queries) { + logger.info('Assets AQL lookup for store number', { qlQuery, storeNumber: normalized }); + + const result = await runAssetsAql(qlQuery, { resultPerPage: 1, includeAttributes: true }); + lastResult = result; + + if (result.status !== 200) { + logger.error('Assets AQL variant failed', { + qlQuery, + status: result.status, + error: result.error, + atlassianError: result.data + }); + continue; + } + + const data = result.data || {}; + const values = Array.isArray(data.values) ? data.values : []; + const total = typeof data.total === 'number' ? data.total : values.length; + + if (total === 0 || values.length === 0) { + logger.warn('Assets AQL returned zero results for variant', { qlQuery, total, storeNumber: normalized }); + continue; + } + + const objectId = extractObjectIdFromResponse(data); + if (!objectId) { + logger.warn('Assets AQL returned results but no extractable id', { + qlQuery, + storeNumber: normalized, + valuesSample: values[0] + }); + continue; + } + + logger.info('Resolved store to Assets object', { storeNumber: normalized, objectId: String(objectId) }); + return [{ objectId: String(objectId) }]; + } + + const total = lastResult?.data?.total ?? 'unknown'; + throw new Error( + `Failed to resolve Store Number ${normalized} via Assets (objectTypeId=${objectTypeId}). ` + + `No results or unparseable id across all variants. Last total=${total}` + ); +} + +/** + * Diagnostic helper: run several AQL variants for a given store number and + * return each result side-by-side, plus workspace/schema/object-type + * introspection and a plain-English diagnosis. Intended for a dev-only debug + * endpoint. + */ +export async function probeAssetsForStore(rawStoreNumber, { extraVariants = [] } = {}) { + const raw = String(rawStoreNumber ?? '').trim(); + const padded = raw ? raw.padStart(5, '0') : ''; + const unpadded = raw.replace(/^0+/, '') || raw; + + const objectTypeId = config.jira.assetsStoreObjectTypeId || '109'; + const schemaId = config.jira.assetsStoreSchemaId; + const attribute = config.jira.assetsStoreNumberAttribute || 'Store Number'; + const attrId = config.jira.assetsStoreNumberAttributeId; + + // A. Workspace discovery — did we get one at all? What are ALL visible workspaces? + const workspacesList = await listAssetsWorkspacesRaw(); + let workspaceIdResolved = null; + let workspaceIdError = null; + try { + workspaceIdResolved = await getAssetsWorkspaceId(); + } catch (e) { + workspaceIdError = e.message; + } + + // B. Schemas the token can actually see. If empty, permissions are the issue. + let schemasProbe = null; + if (workspaceIdResolved) { + const schemasResp = await listAssetsSchemas(); + let visibleSchemas = []; + const data = schemasResp.data; + const list = Array.isArray(data) ? data + : Array.isArray(data?.values) ? data.values + : Array.isArray(data?.objectschemas) ? data.objectschemas + : Array.isArray(data?.objectSchemas) ? data.objectSchemas + : []; + visibleSchemas = list.map(s => ({ + id: s.id ?? null, + name: s.name ?? null, + objectSchemaKey: s.objectSchemaKey ?? s.key ?? null + })); + schemasProbe = { + httpStatus: schemasResp.status, + requestUrl: schemasResp.requestUrl, + count: visibleSchemas.length, + schemas: visibleSchemas, + raw: schemasResp.status === 200 ? undefined : schemasResp.data, + headers: schemasResp.headers + }; + } + + // C. Object type detail — is object type 109 visible? What are its attributes actually called? + let objectTypeProbe = null; + if (workspaceIdResolved) { + const { detail, attributes } = await getAssetsObjectType(objectTypeId); + const attrList = Array.isArray(attributes.data) ? attributes.data + : Array.isArray(attributes.data?.values) ? attributes.data.values + : []; + objectTypeProbe = { + detail: { + httpStatus: detail.status, + requestUrl: detail.requestUrl, + name: detail.data?.name ?? null, + objectSchemaId: detail.data?.objectSchemaId ?? null, + raw: detail.status === 200 ? { id: detail.data?.id, name: detail.data?.name, objectSchemaId: detail.data?.objectSchemaId, description: detail.data?.description } : detail.data, + headers: detail.headers + }, + attributes: { + httpStatus: attributes.status, + requestUrl: attributes.requestUrl, + count: attrList.length, + names: attrList.map(a => ({ + id: a.id ?? null, + name: a.name ?? null, + type: a.type ?? a.defaultType?.name ?? null, + system: a.system ?? null + })), + raw: attributes.status === 200 ? undefined : attributes.data, + headers: attributes.headers + } + }; + } + + // -------- AQL variants -------- + const variants = []; + + if (schemaId) { + variants.push({ label: 'schema_probe', qlQuery: `objectSchemaId = ${schemaId}`, resultPerPage: 5 }); + } + variants.push({ label: 'object_type_probe', qlQuery: `objectTypeId = ${objectTypeId}`, resultPerPage: 5 }); + variants.push({ label: 'object_type_by_name', qlQuery: `objectType = "Store"`, resultPerPage: 5 }); + + if (raw) { + variants.push({ label: 'attr_name_padded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" = "${padded}"`, resultPerPage: 3 }); + variants.push({ label: 'attr_name_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" = "${unpadded}"`, resultPerPage: 3 }); + variants.push({ label: 'attr_name_like_padded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" LIKE "${padded}"`, resultPerPage: 3 }); + + variants.push({ label: 'name_padded', qlQuery: `objectTypeId = ${objectTypeId} AND Name = "${padded}"`, resultPerPage: 3 }); + variants.push({ label: 'name_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND Name = "${unpadded}"`, resultPerPage: 3 }); + variants.push({ label: 'name_like_padded', qlQuery: `objectTypeId = ${objectTypeId} AND Name LIKE "${padded}"`, resultPerPage: 3 }); + + if (schemaId) { + variants.push({ label: 'schema_name_padded', qlQuery: `objectSchemaId = ${schemaId} AND Name = "${padded}"`, resultPerPage: 3 }); + variants.push({ label: 'schema_name_unpadded', qlQuery: `objectSchemaId = ${schemaId} AND Name = "${unpadded}"`, resultPerPage: 3 }); + } + + if (attrId) { + variants.push({ label: 'attr_id_padded', qlQuery: `objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${padded}"`, resultPerPage: 3 }); + variants.push({ label: 'attr_id_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${unpadded}"`, resultPerPage: 3 }); + } + } + + for (const v of extraVariants) { + variants.push({ label: v.label || 'custom', qlQuery: v.qlQuery, resultPerPage: v.resultPerPage ?? 5 }); + } + + const results = []; + for (const v of variants) { + const r = await runAssetsAql(v.qlQuery, { resultPerPage: v.resultPerPage, includeAttributes: true }); + const values = Array.isArray(r.data?.values) ? r.data.values : []; + results.push({ + variant: v.label, + qlQuery: v.qlQuery, + httpStatus: r.status, + statusText: r.statusText, + total: r.data?.total ?? values.length, + objects: values.map(summarizeAssetsObject), + atlassianError: r.status === 200 ? undefined : r.data, + headers: r.headers, + // Truncated raw body so we can see everything Atlassian sent back + // (some tenants surface hints in "hasMoreResults", "objectTypeAttributes", etc.) + rawBody: r.data && typeof r.data === 'object' + ? JSON.parse(JSON.stringify(r.data)) + : r.data + }); + } + + const diagnosis = buildAssetsProbeDiagnosis({ + workspaceIdResolved, + workspaceIdError, + workspacesList, + schemasProbe, + objectTypeProbe, + variantResults: results, + configuredAttribute: attribute, + configuredAttributeId: attrId, + configuredSchemaId: schemaId, + configuredObjectTypeId: objectTypeId, + jiraEmail: config.jira.email + }); + + return { + input: { raw, padded, unpadded }, + config: { + workspaceId: workspaceIdResolved || `error: ${workspaceIdError}`, + objectTypeId, + schemaId: schemaId || null, + attribute, + attrId: attrId || null, + storeCustomFieldId: config.jira.storeCustomFieldId || 'customfield_10261', + authType: config.jira.authType, + jiraEmail: config.jira.email ? maskEmail(config.jira.email) : null + }, + workspace: { + resolvedId: workspaceIdResolved, + error: workspaceIdError, + allVisible: workspacesList.workspaces, + httpStatus: workspacesList.httpStatus + }, + schemas: schemasProbe, + objectType: objectTypeProbe, + variants: results, + diagnosis + }; +} + +function maskEmail(email) { + if (!email || !email.includes('@')) return email || null; + const [local, domain] = email.split('@'); + const shown = local.length <= 3 ? local[0] : `${local.slice(0, 3)}…`; + return `${shown}@${domain}`; +} + +function buildAssetsProbeDiagnosis({ + workspaceIdResolved, + workspaceIdError, + workspacesList, + schemasProbe, + objectTypeProbe, + variantResults, + configuredAttribute, + // eslint-disable-next-line no-unused-vars + configuredAttributeId, + configuredSchemaId, + configuredObjectTypeId, + jiraEmail +}) { + const notes = []; + const suggestions = []; + let likelyCause = 'unknown'; + + const visibleWorkspaces = workspacesList?.workspaces || []; + const email = jiraEmail || '(JIRA_EMAIL)'; + + if (!workspaceIdResolved) { + likelyCause = 'workspace_not_discovered'; + notes.push(`Could not discover Assets workspace id (${workspaceIdError}).`); + suggestions.push('Set JIRA_ASSETS_WORKSPACE_ID explicitly, or ensure /rest/servicedeskapi/assets/workspace is reachable.'); + return { likelyCause, notes, suggestions }; + } + + if (visibleWorkspaces.length > 1) { + notes.push(`Account can see ${visibleWorkspaces.length} Assets workspaces: ${visibleWorkspaces.map(w => w.workspaceId).join(', ')}. Using ${workspaceIdResolved}.`); + suggestions.push('If the Store schema lives in a different workspace, set JIRA_ASSETS_WORKSPACE_ID explicitly.'); + } + + const schemaHttp = schemasProbe?.httpStatus; + const schemaCount = schemasProbe?.count ?? 0; + + if (schemaHttp && schemaHttp !== 200) { + likelyCause = 'schema_list_error'; + notes.push(`GET /objectschema/list returned HTTP ${schemaHttp}. The token cannot list schemas.`); + if (schemaHttp === 401 || schemaHttp === 403) { + suggestions.push(`Add ${email} to an Object Schema role on the target schema in Jira → Assets → Object schemas → Configure → Roles. In Assets, API-token scopes (read:cmdb-*:jira) are NOT sufficient on their own; the user still needs schema-level role membership.`); + } + return { likelyCause, notes, suggestions }; + } + + if (schemaCount === 0) { + likelyCause = 'no_schema_visibility'; + notes.push('GET /objectschema/list returned HTTP 200 with 0 schemas — this account has no visibility to any Assets schema, so every AQL against it returns total=0.'); + suggestions.push(`Ask a Jira Assets admin to add ${email} to a role on the Store schema in Jira → Assets → Object schemas → Configure → Roles. "Object Schema Viewer" or "Object Schema User" is enough to look up store objects; "Developer" is needed to create/update objects.`); + suggestions.push('The four read:cmdb-* / write:cmdb-* scopes on the token are necessary but not sufficient — Assets enforces a separate per-schema role check on top of the OAuth scopes.'); + return { likelyCause, notes, suggestions }; + } + + const visibleSchemaIds = (schemasProbe?.schemas || []).map(s => String(s.id)); + const visibleSchemaSummary = (schemasProbe?.schemas || []).map(s => `${s.id}:${s.name}`).join(', '); + notes.push(`Account can see ${schemaCount} schema(s): ${visibleSchemaSummary}.`); + + if (configuredSchemaId && !visibleSchemaIds.includes(String(configuredSchemaId))) { + likelyCause = 'schema_not_visible'; + notes.push(`Configured JIRA_ASSETS_STORE_SCHEMA_ID=${configuredSchemaId} is NOT in the list of schemas this account can see. AQL against schema ${configuredSchemaId} will always return total=0.`); + suggestions.push(`Ask a Jira Assets admin to add ${email} to a role on the Store schema (id ${configuredSchemaId}) in Jira → Assets → Object schemas → Configure → Roles. "Object Schema Viewer" is enough for read; "Developer" for writes.`); + suggestions.push('Reminder: Jira Assets enforces per-schema role membership on top of OAuth scopes. Granting the token the read:cmdb-* / write:cmdb-* scopes is necessary but NOT sufficient — the underlying user must also be in a role on the schema.'); + if (visibleSchemaIds.length === 1) { + suggestions.push(`Right now the account is only in a role on schema ${visibleSchemaIds[0]} (${visibleSchemaSummary}). Same admin action needs to happen for the Store schema.`); + } + return { likelyCause, notes, suggestions }; + } + + const otDetailStatus = objectTypeProbe?.detail?.httpStatus; + const otAttrStatus = objectTypeProbe?.attributes?.httpStatus; + + if (otDetailStatus && otDetailStatus !== 200) { + if (otDetailStatus === 403) { + likelyCause = 'object_type_forbidden'; + notes.push(`GET /objecttype/${configuredObjectTypeId} returned HTTP 403. The account can list schemas but cannot see this object type — almost always because it is missing an Object Schema role on the Store schema.`); + suggestions.push(`Add ${email} to an Object Schema role on the Store schema in Jira → Assets → Object schemas → Configure → Roles.`); + } else { + likelyCause = 'object_type_not_visible'; + notes.push(`GET /objecttype/${configuredObjectTypeId} returned HTTP ${otDetailStatus}. The configured objectTypeId is either wrong or not visible to this account.`); + suggestions.push(`Verify JIRA_ASSETS_STORE_OBJECT_TYPE_ID matches the actual Store type id in Jira Assets.`); + } + return { likelyCause, notes, suggestions }; + } + + if (otDetailStatus === 200) { + notes.push(`Object type is visible: ${objectTypeProbe.detail.name} (schema ${objectTypeProbe.detail.objectSchemaId}).`); + } + + if (otAttrStatus === 200 && objectTypeProbe.attributes.count > 0) { + const attrNames = objectTypeProbe.attributes.names.map(a => a.name).filter(Boolean); + const attrMatch = attrNames.find(n => n.toLowerCase() === (configuredAttribute || '').toLowerCase()); + if (!attrMatch) { + likelyCause = 'attribute_name_mismatch'; + notes.push(`The configured attribute "${configuredAttribute}" is NOT among the object type's attributes. Actual attribute names: ${attrNames.join(', ')}.`); + const guess = attrNames.find(n => /store|number|store\s*id/i.test(n)); + if (guess) suggestions.push(`Set JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE="${guess}" (or use the id form via JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE_ID).`); + else suggestions.push('Pick the correct attribute from the list above and set JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE (or ...ATTRIBUTE_ID) accordingly.'); + return { likelyCause, notes, suggestions }; + } + notes.push(`Attribute "${configuredAttribute}" exists on the object type.`); + } + + const anyHits = variantResults.some(v => v.total > 0); + if (!anyHits) { + likelyCause = 'value_format_mismatch'; + notes.push('Schema and object type are visible but no AQL variant returned rows. Attribute values are likely stored in a form none of the variants matched.'); + suggestions.push('Re-run the probe without a storeNumber to sample real Store objects: curl "http://localhost:1866/api/wxccai/debug/assetsProbe" — the object_type_probe row will show up to 5 real Store objects with their actual attribute values, so you can see how Store Number is stored (leading zeros, prefix, etc.).'); + } else { + const winners = variantResults.filter(v => v.total > 0).map(v => v.variant); + likelyCause = 'success'; + notes.push(`These variants returned rows: ${winners.join(', ')}. Lock resolveStoreAssetReference to the first one.`); + } + + return { likelyCause, notes, suggestions }; +} + +/** + * Extract an Assets object id from a variety of AQL response shapes. + * Tries top-level, then common list keys, then a bounded deep search. + */ +function extractObjectIdFromResponse(respData) { + if (!respData || typeof respData !== 'object') return null; + + if (respData.id) return respData.id; + if (respData.objectId) return respData.objectId; + + const listKeys = ['values', 'objectEntries', 'objects', 'objectList', 'results', 'items']; + for (const key of listKeys) { + const list = respData[key]; + if (Array.isArray(list)) { + for (const item of list) { + if (item && typeof item === 'object') { + if (item.id) return item.id; + if (item.objectId) return item.objectId; + if (item.object && item.object.id) return item.object.id; + if (item.attributes && item.attributes.id) return item.attributes.id; + } + } + } + } + + function deepFind(obj, depth = 0) { + if (depth > 6 || obj == null || typeof obj !== 'object') return null; + if (obj.id && (typeof obj.id === 'string' || typeof obj.id === 'number')) return obj.id; + if (obj.objectId && (typeof obj.objectId === 'string' || typeof obj.objectId === 'number')) return obj.objectId; + if (Array.isArray(obj)) { + for (const el of obj) { + const found = deepFind(el, depth + 1); + if (found) return found; + } + } else { + for (const k of Object.keys(obj)) { + const found = deepFind(obj[k], depth + 1); + if (found) return found; + } + } + return null; + } + + return deepFind(respData); +} diff --git a/src/services/jira/attachments.js b/src/services/jira/attachments.js new file mode 100644 index 0000000..d56957a --- /dev/null +++ b/src/services/jira/attachments.js @@ -0,0 +1,154 @@ +// Jira attachments + Webex CC transcript conversion. +// Downloads happen via downloadClient (own retry policy); uploads use jiraClient. +import FormData from 'form-data'; +import logger from '../../utilities/logger.js'; +import { jiraClient, downloadClient } from './client.js'; + +/** + * Shared helper: POST a Buffer as multipart attachment to the core Jira + * attachments endpoint. + * - Uses jiraClient (correct base + auth). + * - Spreads form.getHeaders() so boundary is set. + * - Cleans any charset from Content-Type (prevents 415). + * - Retry loop only around the API call (download is caller's job). + * - Fail-fast on 401/403 (scope/perms). + */ +async function attachBufferToJira(jiraKey, fileBuffer, fileName) { + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const form = new FormData(); + form.append('file', fileBuffer, fileName); + + const formHeaders = form.getHeaders(); + if (formHeaders['content-type']) { + formHeaders['content-type'] = formHeaders['content-type'].replace(/;\s*charset=[^;]*/i, ''); + } + + const uploadPath = `/rest/api/3/issue/${jiraKey}/attachments`; + + await jiraClient.post(uploadPath, form, { + headers: { + 'X-Atlassian-Token': 'no-check', + ...formHeaders + }, + timeout: 15000 + }); + + logger.info('File attached successfully', { jiraKey, fileName, attempt }); + return; + } catch (err) { + const status = err.response?.status; + logger.error('Jira file attach attempt failed', { + jiraKey, + fileName, + attempt, + status, + responseData: err.response?.data, + responseHeaders: err.response?.headers ? Object.fromEntries( + Object.entries(err.response.headers).filter(([k]) => !k.toLowerCase().includes('auth')) + ) : undefined + }); + if (status === 401 || status === 403) { + throw err; + } + if (attempt === 3) throw err; + await new Promise(r => setTimeout(r, attempt * 1500)); + } + } +} + +/** + * Download a file from the given URL (S3 pre-signed) *once* and attach using + * the core Jira attachments API. Download happens outside the retry loop + * because the signed URL expires (~1800s). + */ +export async function attachFileToJira(jiraKey, fileUrl, fileName) { + let fileBuffer; + try { + const dl = await downloadClient.get(fileUrl, { + responseType: 'arraybuffer' + }); + fileBuffer = Buffer.from(dl.data); + } catch (dlErr) { + logger.error('Failed to download file from S3 for attachment (URL likely expired on replay)', { + jiraKey, + fileName, + url: fileUrl, + status: dlErr.response?.status, + message: dlErr.message + }); + throw dlErr; + } + + await attachBufferToJira(jiraKey, fileBuffer, fileName); +} + +/** + * Convert a Webex-style JSON transcript to human-readable text. + * Returns null if the shape isn't recognized. + */ +function formatTranscriptToHumanReadable(data) { + if (!data || !Array.isArray(data.responseContents)) return null; + const lines = []; + lines.push(`Transcript`); + if (data.interactionId) lines.push(`Interaction ID: ${data.interactionId}`); + if (data.languageCode) lines.push(`Language: ${data.languageCode}`); + lines.push(''); + for (const entry of data.responseContents) { + const res = entry.recognitionResult; + if (!res || !res.alternatives || !res.alternatives[0]) continue; + const role = (res.role || 'UNKNOWN').toUpperCase(); + const alt = res.alternatives[0]; + const transcript = (alt.transcript || '').trim(); + if (!transcript) continue; + let ts = ''; + const words = alt.words || []; + if (words.length > 0) { + const start = words[0].start_time || {}; + const totalSec = (start.seconds || 0) + Math.floor((start.nanos || 0) / 1e9); + const min = Math.floor(totalSec / 60); + const sec = Math.floor(totalSec % 60); + ts = `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}] `; + } + lines.push(`${ts}${role}: ${transcript}`); + } + return lines.join('\n'); +} + +async function fetchAndConvertTranscript(url) { + try { + const resp = await downloadClient.get(url, { timeout: 10000 }); + return formatTranscriptToHumanReadable(resp.data); + } catch (e) { + logger.warn(`Failed to fetch/convert transcript: ${e.message}`); + return null; + } +} + +/** + * Download the JSON transcript, convert to human-readable text, and attach as + * `-readable.txt`. Failures are swallowed so they don't mark the + * original JSON attach as failed. + */ +export async function attachReadableTranscript(jiraKey, transcriptUrl, originalFileName = null) { + const readable = await fetchAndConvertTranscript(transcriptUrl); + if (!readable) { + logger.warn('Readable transcript conversion yielded no content (check transcript JSON shape or URL)', { jiraKey }); + return false; + } + const base = (originalFileName || `transcript-${jiraKey}`).replace(/\.json$/i, ''); + const fileName = `${base}-readable.txt`; + + try { + await attachBufferToJira(jiraKey, Buffer.from(readable, 'utf8'), fileName); + return true; + } catch (err) { + logger.error('Readable transcript attach failed (non-fatal)', { + jiraKey, + fileName, + error: err.response?.data?.message || err.message, + status: err.response?.status + }); + return false; + } +} diff --git a/src/services/jira/client.js b/src/services/jira/client.js new file mode 100644 index 0000000..fa6b4d3 --- /dev/null +++ b/src/services/jira/client.js @@ -0,0 +1,84 @@ +// Foundational Jira module: shared axios instances + tiny ADF helper. +// Nothing else in services/jira/* should import axios or axios-retry directly. +import axios from 'axios'; +import axiosRetry from 'axios-retry'; +import config from '../../config/index.js'; +import logger from '../../utilities/logger.js'; + +// Reusable Jira client with flexible auth. No default Content-Type on the +// instance because callers need both JSON bodies AND multipart/form-data +// (attachments). Content-Type is set explicitly per-request when needed. +const createJiraClient = () => { + const headers = {}; + const effectiveBase = config.jira.baseUrl || '(not configured)'; + logger.debug(`[jira] baseUrl=${effectiveBase} authType=${config.jira.authType}`); + + if (config.jira.authType === 'bearer') { + headers.Authorization = `Bearer ${config.jira.apiToken}`; + logger.info('Using Jira Bearer Token authentication'); + } else { + const authStr = `${config.jira.email}:${config.jira.apiToken}`; + headers.Authorization = `Basic ${Buffer.from(authStr).toString('base64')}`; + logger.info('Using Jira Basic Auth'); + } + + const client = axios.create({ + baseURL: config.jira.baseUrl, + headers, + }); + + // Retry policy scoped to this client only. Never mutates the default + // axios instance — anything else that needs retries uses its own client. + axiosRetry(client, { + retries: 3, + retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000, + retryCondition: (error) => { + return axiosRetry.isNetworkOrIdempotentRequestError(error) || + error.response?.status === 429 || + error.response?.status >= 500; + } + }); + + return client; +}; + +export const jiraClient = createJiraClient(); + +// Dedicated instance for pre-signed S3 downloads (audio + transcript files +// from Webex CC). Separate from jiraClient because: +// 1. No baseURL — always pass the full pre-signed URL. +// 2. No Authorization header — the S3 URL is already signed. +// 3. We want retries — S3 pre-signed downloads are the flakiest thing +// in the pipeline (transient 5xx, TLS resets, TCP timeouts). +export const downloadClient = axios.create({ timeout: 20000 }); +axiosRetry(downloadClient, { + retries: 3, + retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000, + retryCondition: (error) => { + return axiosRetry.isNetworkOrIdempotentRequestError(error) || + error.response?.status === 429 || + error.response?.status >= 500; + } +}); + +/** + * Convert a plain string to a minimal ADF document. + * ADF is what /rest/api/3/... expects for description/comment bodies. + * Blank lines split paragraphs; single newlines become hardBreak nodes. + */ +export function plainTextToAdf(text) { + const safe = (text ?? '').toString(); + if (!safe) { + return { version: 1, type: 'doc', content: [] }; + } + const paragraphs = safe.split(/\n{2,}/).map(block => { + const parts = block.split('\n'); + const content = []; + parts.forEach((line, idx) => { + if (line.length) content.push({ type: 'text', text: line }); + if (idx < parts.length - 1) content.push({ type: 'hardBreak' }); + }); + return { type: 'paragraph', content }; + }); + return { version: 1, type: 'doc', content: paragraphs }; +} diff --git a/src/services/jira/comments.js b/src/services/jira/comments.js new file mode 100644 index 0000000..0c8904f --- /dev/null +++ b/src/services/jira/comments.js @@ -0,0 +1,121 @@ +// Jira issue comments: fetch (normalized), add plain, and post structured +// Webex CC summary blocks. All comment ADF construction lives here. +import config from '../../config/index.js'; +import logger from '../../utilities/logger.js'; +import { adfToPlainText } from '../../utilities/adfToPlainText.js'; +import { jiraClient, plainTextToAdf } from './client.js'; + +/** + * Fetch public comments for an issue, normalized to + * { author, body (plain text), created, createdIso }. + * Uses the core /rest/api/3/issue/{key}/comment endpoint (servicedeskapi's + * variant can require different auth/perms with the current cloudId + Basic + * auth setup). + */ +export async function fetchPublicComments(key) { + const url = `/rest/api/3/issue/${key}/comment`; + try { + const response = await jiraClient.get(url); + const values = response.data?.values || []; + return values.map(comment => ({ + author: comment.author?.displayName || comment.author?.name || 'Unknown', + body: adfToPlainText(comment.body), + created: comment.created, + createdIso: typeof comment.created === 'string' ? comment.created : (comment.created?.iso8601 || comment.created || null) + })); + } catch (error) { + logger.warn('Failed to fetch public comments:', error.message); + return []; + } +} + +/** + * Add a comment to an issue. `text` is plain; converted to ADF here. + * If `internal: true`, restricts visibility to `config.jira.commentVisibilityRole` + * (same behavior as postWebexSummaryComment). + */ +export async function addComment(key, text, { internal = false } = {}) { + if (!key || !/^[A-Z]+-\d+$/.test(key)) { + throw new Error(`Invalid ticket key: "${key}"`); + } + if (!text || !String(text).trim()) { + throw new Error('Comment text is required'); + } + + const payload = { body: plainTextToAdf(String(text)) }; + if (internal) { + payload.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' }; + } + + try { + const { data } = await jiraClient.post(`/rest/api/3/issue/${key}/comment`, payload, { + headers: { 'Content-Type': 'application/json' } + }); + logger.info('Comment posted', { key, commentId: data?.id, internal }); + return { key, commentId: data?.id, internal }; + } catch (err) { + logger.error('addComment failed', { key, status: err.response?.status, details: err.response?.data }); + const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); + e.status = err.response?.status; + e.details = err.response?.data; + throw e; + } +} + +/** + * Build a clean ADF comment from the Webex CC AI summaries object and post it + * to the Jira issue. Restricted visibility (via role) so only the configured + * role sees the summary + attachment references. + */ +export async function postWebexSummaryComment(jiraKey, summaries, attachedFiles = []) { + const items = [ + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Initial Contact Reason: ${summaries.intialContactReason || 'N/A'}` }] }] }, + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Additional Context: ${summaries.additionalContext || 'N/A'}` }] }] }, + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Key Actions Taken: ${summaries.keyActionsTake || 'N/A'}` }] }] }, + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Next Steps: ${summaries.nextSteps || 'N/A'}` }] }] }, + { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Resolution: ${summaries.resolution || 'N/A'}` }] }] } + ]; + + const content = [ + { + type: "heading", + attrs: { level: 3 }, + content: [{ type: "text", text: "Webex Contact Center Summary" }] + }, + { type: "bulletList", content: items }, + { + type: "paragraph", + content: [{ type: "text", text: `Posted via Webex Integration — ${new Date().toISOString()}` }] + } + ]; + + if (Array.isArray(attachedFiles) && attachedFiles.length > 0) { + content.push({ + type: "paragraph", + content: [{ + type: "text", + text: `Attached files (internal): ${attachedFiles.join(', ')}` + }] + }); + } + + const commentPayload = { + body: { + version: 1, + type: "doc", + content + }, + visibility: { + type: "role", + value: config.jira.commentVisibilityRole || 'Service Desk Team' + } + }; + + await jiraClient.post( + `/rest/api/3/issue/${jiraKey}/comment`, + commentPayload, + { headers: { 'Content-Type': 'application/json' } } + ); + + logger.info('Clean summary comment posted (restricted)', { jiraKey, attachedFiles }); +} diff --git a/src/services/jira/issues.js b/src/services/jira/issues.js new file mode 100644 index 0000000..4ce4b4c --- /dev/null +++ b/src/services/jira/issues.js @@ -0,0 +1,290 @@ +// Jira issue lifecycle: fetch, search-by-reporter, status, update, transitions, +// close. All project-agnostic (works for any project the token can see) and +// uses the core /rest/api/3/issue/... endpoints. +import logger from '../../utilities/logger.js'; +import config from '../../config/index.js'; +import { jiraClient, plainTextToAdf } from './client.js'; +import { fetchPublicComments } from './comments.js'; + +const KEY_RE = /^[A-Z]+-\d+$/; + +export async function fetchJiraIssue(key) { + if (!key || !KEY_RE.test(key)) { + throw new Error(`Invalid ticket key: "${key}"`); + } + + const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,created,updated,priority`; + try { + const response = await jiraClient.get(url); + return response.data; + } catch (error) { + logger.error('Fetch Jira issue failed:', error.response?.data || error.message); + throw new Error(`Issue fetch failed: ${error.message}`); + } +} + +export async function fetchPlainDescription(key) { + const payload = { expression: "issue.description.plainText", context: { issue: { key } } }; + try { + const response = await jiraClient.post('/rest/api/3/expression/evaluate', payload, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data.value || 'No description available.'; + } catch (error) { + logger.warn('Failed to fetch plain description:', error.message); + return 'No description available.'; + } +} + +// Kept for future use (email → accountId lookup). Not currently called; the +// reporter search uses email directly in JQL. +// eslint-disable-next-line no-unused-vars +async function getAccountIdFromEmail(email) { + if (!email) { + throw new Error('Email is required'); + } + + const url = `/rest/api/3/user/search?query=${encodeURIComponent(email)}&maxResults=10`; + try { + const response = await jiraClient.get(url); + const users = response.data || []; + + if (users.length === 0) { + throw new Error(`No users found matching "${email}"`); + } + + let user = users.find(u => u.emailAddress?.toLowerCase() === email.toLowerCase()); + if (!user && users.length > 0) { + user = users[0]; + } + + if (!user?.accountId) { + throw new Error(`No usable accountId found for "${email}"`); + } + + logger.info(`Using accountId ${user.accountId} for email "${email}"`); + return user.accountId; + } catch (err) { + throw new Error(`User lookup failed: ${err.message}`); + } +} + +/** + * Search open tickets reported by a given email (across CS/SS/SUPPORT + * projects). Enriches each result with plain-text description + last 6 + * public comments so downstream (Grok) has full context in one round trip. + */ +export async function searchOpenTicketsByReporterEmail(email) { + if (!email || typeof email !== 'string' || email.trim() === '') { + throw new Error("Email is required"); + } + + const jql = `project in (CS, SS, SUPPORT) + AND reporter = "${email}" + AND statusCategory != Done + ORDER BY updated DESC`; + + try { + const response = await jiraClient.post('/rest/api/3/search/jql', { + jql: jql, + maxResults: 8, + fields: ["key", "summary", "status", "updated", "description"], + expand: "comments" + }, { + headers: { 'Content-Type': 'application/json' } + }); + + const issues = response.data.issues || []; + + const enrichedIssues = await Promise.all( + issues.map(async (issue) => { + const key = issue.key; + try { + const [plainDesc, publicComments] = await Promise.all([ + fetchPlainDescription(key).catch(() => "No description available."), + fetchPublicComments(key).catch(() => []) + ]); + + issue.enrichedNotes = { + description: plainDesc, + publicComments: publicComments.slice(-6) + }; + } catch (err) { + logger.warn(`Failed to enrich notes for ${key}:`, err.message); + issue.enrichedNotes = { description: "Notes unavailable.", publicComments: [] }; + } + return issue; + }) + ); + + return enrichedIssues; + } catch (error) { + logger.error('Jira reporter search failed:', error.response?.data || error.message); + throw new Error(`Failed to search tickets reported by ${email}: ${error.message}`); + } +} + +/** + * Compact, purpose-built status view — no Grok, no comment enrichment. + * Use this when a caller just wants "where is this ticket right now?". + */ +export async function getTicketStatus(key) { + if (!key || !KEY_RE.test(key)) { + throw new Error(`Invalid ticket key: "${key}"`); + } + + const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,reporter,priority,resolution,created,updated,labels`; + try { + const { data } = await jiraClient.get(url); + const f = data.fields || {}; + return { + key: data.key, + summary: f.summary || null, + status: f.status?.name || null, + statusCategory: f.status?.statusCategory?.key || null, + assignee: f.assignee?.displayName || f.assignee?.emailAddress || null, + reporter: f.reporter?.displayName || f.reporter?.emailAddress || null, + priority: f.priority?.name || null, + resolution: f.resolution?.name || null, + labels: f.labels || [], + created: f.created || null, + updated: f.updated || null + }; + } catch (err) { + logger.error('getTicketStatus failed', { key, status: err.response?.status, details: err.response?.data }); + const e = new Error(`Failed to fetch status for ${key}: ${err.message}`); + e.status = err.response?.status; + e.details = err.response?.data; + throw e; + } +} + +/** + * Partial issue update. Accepts flat, friendly fields: + * { summary, description, priority, labels, assigneeAccountId, additional } + * `additional` is merged raw into the `fields` object (e.g. customfield_*). + * `description` is a plain string; converted to ADF here. + */ +export async function updateTicket(key, updates = {}) { + if (!key || !KEY_RE.test(key)) { + throw new Error(`Invalid ticket key: "${key}"`); + } + + const fields = {}; + if (updates.summary !== undefined) fields.summary = String(updates.summary); + if (updates.description !== undefined) fields.description = plainTextToAdf(updates.description); + if (updates.priority) fields.priority = { name: String(updates.priority) }; + if (Array.isArray(updates.labels)) fields.labels = updates.labels.map(String); + if (updates.assigneeAccountId) fields.assignee = { accountId: String(updates.assigneeAccountId) }; + if (updates.additional && typeof updates.additional === 'object') Object.assign(fields, updates.additional); + + if (Object.keys(fields).length === 0) { + throw new Error('updateTicket called with no updatable fields'); + } + + try { + await jiraClient.put(`/rest/api/3/issue/${key}`, { fields }, { + headers: { 'Content-Type': 'application/json' } + }); + logger.info('Ticket updated', { key, fieldKeys: Object.keys(fields) }); + return { key, updated: Object.keys(fields) }; + } catch (err) { + logger.error('updateTicket failed', { key, status: err.response?.status, details: err.response?.data }); + const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); + e.status = err.response?.status; + e.details = err.response?.data; + throw e; + } +} + +/** + * Fetch available workflow transitions for an issue. Useful for both the + * client picking a transition manually and for closeTicket() below. + */ +export async function getTransitions(key) { + if (!key || !KEY_RE.test(key)) { + throw new Error(`Invalid ticket key: "${key}"`); + } + try { + const { data } = await jiraClient.get(`/rest/api/3/issue/${key}/transitions`); + return (data.transitions || []).map(t => ({ + id: t.id, + name: t.name, + to: { id: t.to?.id, name: t.to?.name, statusCategory: t.to?.statusCategory?.key }, + hasScreen: !!t.hasScreen + })); + } catch (err) { + logger.error('getTransitions failed', { key, status: err.response?.status, details: err.response?.data }); + throw new Error(`Failed to fetch transitions for ${key}: ${err.message}`); + } +} + +/** + * Execute a specific transition. Optionally set a resolution and/or append a + * comment in the same call (both are fields the transition screen can accept). + */ +export async function transitionTicket(key, transitionId, { resolution, comment, internal = false, additionalFields } = {}) { + if (!key || !KEY_RE.test(key)) { + throw new Error(`Invalid ticket key: "${key}"`); + } + if (!transitionId) throw new Error('transitionId is required'); + + const payload = { transition: { id: String(transitionId) } }; + + const fields = { ...(additionalFields || {}) }; + if (resolution) fields.resolution = { name: String(resolution) }; + if (Object.keys(fields).length) payload.fields = fields; + + if (comment) { + const commentEntry = { add: { body: plainTextToAdf(String(comment)) } }; + if (internal) { + commentEntry.add.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' }; + } + payload.update = { comment: [commentEntry] }; + } + + try { + await jiraClient.post(`/rest/api/3/issue/${key}/transitions`, payload, { + headers: { 'Content-Type': 'application/json' } + }); + logger.info('Ticket transitioned', { key, transitionId, resolution }); + return { key, transitionId, resolution: resolution || null }; + } catch (err) { + logger.error('transitionTicket failed', { + key, transitionId, status: err.response?.status, details: err.response?.data + }); + const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); + e.status = err.response?.status; + e.details = err.response?.data; + throw e; + } +} + +/** + * Convenience: find a "closing" transition and execute it. + * Prefers explicit `transitionName` if provided, otherwise picks the first + * transition whose target status is in category "done" (Jira's canonical + * category for closed/resolved/completed), falling back to a name-based match. + */ +export async function closeTicket(key, { transitionName, resolution = 'Done', comment, internal = false } = {}) { + const transitions = await getTransitions(key); + if (transitions.length === 0) { + throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`); + } + + let chosen = null; + if (transitionName) { + chosen = transitions.find(t => t.name.toLowerCase() === transitionName.toLowerCase()); + if (!chosen) { + throw new Error(`Transition "${transitionName}" not available for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`); + } + } else { + chosen = transitions.find(t => t.to?.statusCategory === 'done') + || transitions.find(t => /done|closed|resolved|complete/i.test(t.name)); + if (!chosen) { + throw new Error(`Could not find a closing transition for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`); + } + } + + return transitionTicket(key, chosen.id, { resolution, comment, internal }); +} diff --git a/src/services/jira/jsmRequests.js b/src/services/jira/jsmRequests.js new file mode 100644 index 0000000..29a36d5 --- /dev/null +++ b/src/services/jira/jsmRequests.js @@ -0,0 +1,149 @@ +// Jira Service Management (JSM) request creation. +// Uses /rest/servicedeskapi/request to create proper JSM customer requests +// with request types. This is separate from the corporate-project ticket +// flow (see issues.js). Depends on assets.js for store resolution. +import config from '../../config/index.js'; +import logger from '../../utilities/logger.js'; +import { jiraClient } from './client.js'; +import { resolveStoreAssetReference } from './assets.js'; + +const REQUEST_TYPE_MAP = { + // Point of Sale + 'Register Not functioning properly': 269, + 'Unable to login': 275, + 'Business report issue': 267, + 'Broken device / hardware': 266, + + // Hardware + 'Broken Device / Hardware': 266, + 'Report Missing Hardware': 273, + 'Request Additional Hardware': 274, + + // Technology + 'Business Report Issue': 267, + 'Report an Issue with Sterling Application': 272, + 'Omni Turn Off / On': 268, + 'Report a Traffic Counter Issue': 271, + 'Report a Technology issue': 270, + 'UKG Pro / Workforce Management Issues': 426, + 'Store Transportation Request': 493, +}; + +// SubTypes that require a storeNumber. Every currently-supported subType maps +// to a request type whose Store Number field is `required: true` in JSM (see +// ss-fields-*.json). Kept as an explicit set so a future subType that does NOT +// require Store Number can be added by simply omitting it from this set. +const SUBTYPES_REQUIRING_STORE_NUMBER = new Set(Object.keys(REQUEST_TYPE_MAP)); + +export function getSupportedSSSubTypes() { + return Object.keys(REQUEST_TYPE_MAP); +} + +export { REQUEST_TYPE_MAP }; + +/** + * Create a Store Support ticket (JSM request) using the Service Desk API. + * @param {Object} params + * @param {string} params.subType - Exact key from REQUEST_TYPE_MAP (e.g. "Register Not functioning properly") + * @param {string} [params.onBehalfOf] - email or accountId (becomes raiseOnBehalfOf) + * @param {string} params.summary + * @param {string} [params.description] + * @param {string|number} [params.storeNumber] + * @param {Object} [params.additional] - extra customfield_* values merged into requestFieldValues + */ +export async function createSSRequest(params = {}) { + const { + subType, + onBehalfOf, + summary, + description, + storeNumber, + additional = {} + } = params; + + if (!subType || !summary) { + const err = new Error('subType and summary are required'); + err.status = 400; + throw err; + } + + const requestTypeId = REQUEST_TYPE_MAP[subType]; + if (!requestTypeId) { + const err = new Error(`Unknown subType: "${subType}". Must be one of the supported values.`); + err.status = 400; + throw err; + } + + // Fail-fast: every current subType requires Store Number. Catching this + // client-side gives a clean API error instead of forwarding to Jira and + // getting back an opaque "Please provide a value for required field + // 'Store Number'" that references Jira internals. + const normalizedStoreNumber = storeNumber != null && String(storeNumber).trim() !== '' + ? String(storeNumber).trim() + : null; + if (SUBTYPES_REQUIRING_STORE_NUMBER.has(subType) && !normalizedStoreNumber) { + const err = new Error(`storeNumber is required for subType "${subType}"`); + err.status = 400; + throw err; + } + + const serviceDeskId = config.jira.serviceDeskId || '170'; + const storeCustomField = config.jira.storeCustomFieldId || 'customfield_10261'; + + const requestFieldValues = { + summary, + description: description || summary, + ...additional + }; + + if (normalizedStoreNumber) { + const storeRef = await resolveStoreAssetReference(normalizedStoreNumber); + if (storeRef) { + requestFieldValues[storeCustomField] = storeRef; + } + } + + const payload = { + serviceDeskId: String(serviceDeskId), + requestTypeId: String(requestTypeId), + requestFieldValues + }; + + if (onBehalfOf) { + payload.raiseOnBehalfOf = onBehalfOf; + } + + try { + const response = await jiraClient.post( + '/rest/servicedeskapi/request', + payload, + { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + ); + + const data = response.data; + logger.info('SS ticket created successfully', { + issueKey: data?.issueKey, + subType, + storeNumber: String(storeNumber || '').padStart(5, '0') + }); + return data; + } catch (err) { + const errData = err.response?.data || {}; + const message = errData.errorMessage || errData.message || err.message || 'Unknown error creating SS request'; + logger.error('Failed to create SS request', { + subType, + storeNumber, + status: err.response?.status, + details: errData + }); + const error = new Error(message); + error.status = err.response?.status; + error.details = errData; + throw error; + } +} diff --git a/src/services/jiraService.js b/src/services/jiraService.js index da225c0..1ffa0fc 100644 --- a/src/services/jiraService.js +++ b/src/services/jiraService.js @@ -1,1451 +1,86 @@ -import path from 'path'; -import axios from 'axios'; -import axiosRetry from 'axios-retry'; -import FormData from 'form-data'; -import config from '../config/index.js'; -import logger from '../utilities/logger.js'; -import { adfToPlainText } from '../utilities/adfToPlainText.js'; +// Barrel re-export for the Jira service. +// +// The implementation lives in ./jira/* — split from a monolithic ~1300 line +// file into six focused modules (see issue #6): +// - jira/client.js shared axios instances (jiraClient, downloadClient) +// and the plainTextToAdf helper +// - jira/issues.js fetch / search / status / update / transitions / close +// - jira/comments.js fetch (normalized), addComment, Webex summary comment +// - jira/attachments.js attachFileToJira, attachReadableTranscript +// - jira/assets.js AQL, resolveStoreAssetReference, probeAssetsForStore +// - jira/jsmRequests.js REQUEST_TYPE_MAP, createSSRequest, subtype helpers +// +// This file exists so existing callers (`import * as jiraService`, +// `import { jiraClient }`) keep working without knowing about the layout. +// Prefer importing from the submodules directly in new code. + +export { jiraClient, downloadClient, plainTextToAdf } from './jira/client.js'; + +export { + fetchJiraIssue, + fetchPlainDescription, + searchOpenTicketsByReporterEmail, + getTicketStatus, + updateTicket, + getTransitions, + transitionTicket, + closeTicket, +} from './jira/issues.js'; + +export { + fetchPublicComments, + addComment, + postWebexSummaryComment, +} from './jira/comments.js'; + +export { + attachFileToJira, + attachReadableTranscript, +} from './jira/attachments.js'; + +export { + listAssetsSchemas, + getAssetsObjectType, + resolveStoreAssetReference, + probeAssetsForStore, +} from './jira/assets.js'; + +export { + REQUEST_TYPE_MAP, + getSupportedSSSubTypes, + createSSRequest, +} from './jira/jsmRequests.js'; + +// Preserve the previous default export shape so `jiraService.createSSRequest` +// etc. still work for any callers that used the default import. +import { jiraClient } from './jira/client.js'; +import { + fetchJiraIssue, + fetchPlainDescription, + searchOpenTicketsByReporterEmail, + getTicketStatus, + updateTicket, + getTransitions, + transitionTicket, + closeTicket, +} from './jira/issues.js'; +import { + fetchPublicComments, + addComment, + postWebexSummaryComment, +} from './jira/comments.js'; +import { + attachFileToJira, + attachReadableTranscript, +} from './jira/attachments.js'; +import { + probeAssetsForStore, +} from './jira/assets.js'; +import { + getSupportedSSSubTypes, + createSSRequest, +} from './jira/jsmRequests.js'; -// Create a reusable Jira client with flexible auth (kept as basic per current requirements). -// We deliberately do NOT put a default 'Content-Type' on the instance because we -// need to support both JSON bodies and multipart/form-data (for attachments). -// Content-Type is set explicitly on the calls that need it. -const createJiraClient = () => { - const headers = {}; - const effectiveBase = config.jira.baseUrl || '(not configured)'; - logger.debug(`[jira] baseUrl=${effectiveBase} authType=${config.jira.authType}`); - - if (config.jira.authType === 'bearer') { - headers.Authorization = `Bearer ${config.jira.apiToken}`; - logger.info('Using Jira Bearer Token authentication'); - } else { - // Classic Basic Auth (email:apiToken) - const authStr = `${config.jira.email}:${config.jira.apiToken}`; - headers.Authorization = `Basic ${Buffer.from(authStr).toString('base64')}`; - logger.info('Using Jira Basic Auth'); - } - - const client = axios.create({ - baseURL: config.jira.baseUrl, - headers, - }); - - // Retry policy scoped to this client only. We used to also mutate the - // global axios instance from wxccRoutes.js, which caused every bare axios - // call (S3 downloads, Assets AQL, etc.) to inherit these retries as a - // side-effect. That's been removed — anything that needs retries now uses - // an explicit instance (jiraClient here, downloadClient below). - axiosRetry(client, { - retries: 3, - retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000, - retryCondition: (error) => { - return axiosRetry.isNetworkOrIdempotentRequestError(error) || - error.response?.status === 429 || - error.response?.status >= 500; - } - }); - - return client; -}; - -export const jiraClient = createJiraClient(); - -// Dedicated instance for fetching pre-signed S3 URLs (audio + transcript -// files that Webex CC hands us). Kept separate from jiraClient because: -// 1. Different base URL (no baseURL — we always pass the full pre-signed URL). -// 2. No Authorization header (the S3 URL is already signed). -// 3. We want retries here — S3 pre-signed downloads are the flakiest thing -// in the pipeline (transient 5xx, TLS resets, TCP timeouts). -// Kept private (not exported); callers in this module use it directly. -const downloadClient = axios.create({ timeout: 20000 }); -axiosRetry(downloadClient, { - retries: 3, - retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000, - retryCondition: (error) => { - return axiosRetry.isNetworkOrIdempotentRequestError(error) || - error.response?.status === 429 || - error.response?.status >= 500; - } -}); - -// ======================== -// Existing Functions (updated to use jiraClient where possible) -// ======================== - -export async function fetchJiraIssue(key) { - if (!key || !/^[A-Z]+-\d+$/.test(key)) { - throw new Error(`Invalid ticket key: "${key}"`); - } - - const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,created,updated,priority`; - - try { - const response = await jiraClient.get(url); - return response.data; - } catch (error) { - logger.error('Fetch Jira issue failed:', error.response?.data || error.message); - throw new Error(`Issue fetch failed: ${error.message}`); - } -} - -export async function fetchPlainDescription(key) { - const payload = { expression: "issue.description.plainText", context: { issue: { key } } }; - try { - const response = await jiraClient.post('/rest/api/3/expression/evaluate', payload, { - headers: { 'Content-Type': 'application/json' } - }); - return response.data.value || 'No description available.'; - } catch (error) { - logger.warn('Failed to fetch plain description:', error.message); - return 'No description available.'; - } -} - -export async function fetchPublicComments(key) { - // Use standard /rest/api/3/issue/{key}/comment (switched from servicedeskapi - // because the latter can require different auth/permissions under the current - // cloudId + Basic auth setup). We still normalize the shape for downstream - // consumers in wxccRoutes and grokService (author string, plain-text body, - // consistent date fields). - const url = `/rest/api/3/issue/${key}/comment`; - try { - const response = await jiraClient.get(url); - const values = response.data?.values || []; - return values.map(comment => ({ - author: comment.author?.displayName || comment.author?.name || 'Unknown', - body: adfToPlainText(comment.body), - created: comment.created, - createdIso: typeof comment.created === 'string' ? comment.created : (comment.created?.iso8601 || comment.created || null) - })); - } catch (error) { - logger.warn('Failed to fetch public comments:', error.message); - return []; - } -} - -async function getAccountIdFromEmail(email) { - if (!email) { - throw new Error('Email is required'); - } - - const url = `/rest/api/3/user/search?query=${encodeURIComponent(email)}&maxResults=10`; - try { - const response = await jiraClient.get(url); - const users = response.data || []; - - if (users.length === 0) { - throw new Error(`No users found matching "${email}"`); - } - - let user = users.find(u => u.emailAddress?.toLowerCase() === email.toLowerCase()); - if (!user && users.length > 0) { - user = users[0]; - } - - if (!user?.accountId) { - throw new Error(`No usable accountId found for "${email}"`); - } - - logger.info(`Using accountId ${user.accountId} for email "${email}"`); - return user.accountId; - } catch (err) { - throw new Error(`User lookup failed: ${err.message}`); - } -} - -// Search functions (updated to use jiraClient) -// Updated: Search open tickets by REPORTER using email directly in JQL (no user lookup needed) -export async function searchOpenTicketsByReporterEmail(email) { - if (!email || typeof email !== 'string' || email.trim() === '') { - throw new Error("Email is required"); - } - - const jql = `project in (CS, SS, SUPPORT) - AND reporter = "${email}" - AND statusCategory != Done - ORDER BY updated DESC`; - - try { - const response = await jiraClient.post('/rest/api/3/search/jql', { - jql: jql, - maxResults: 8, - fields: ["key", "summary", "status", "updated", "description"], - expand: "comments" - }, { - headers: { 'Content-Type': 'application/json' } - }); - - const issues = response.data.issues || []; - - // Enrich with description + public comments - const enrichedIssues = await Promise.all( - issues.map(async (issue) => { - const key = issue.key; - try { - const [plainDesc, publicComments] = await Promise.all([ - fetchPlainDescription(key).catch(() => "No description available."), - fetchPublicComments(key).catch(() => []) - ]); - - issue.enrichedNotes = { - description: plainDesc, - publicComments: publicComments.slice(-6) - }; - } catch (err) { - logger.warn(`Failed to enrich notes for ${key}:`, err.message); - issue.enrichedNotes = { description: "Notes unavailable.", publicComments: [] }; - } - return issue; - }) - ); - - return enrichedIssues; - } catch (error) { - logger.error('Jira reporter search failed:', error.response?.data || error.message); - throw new Error(`Failed to search tickets reported by ${email}: ${error.message}`); - } -} - -// ======================== -// Jira helper operations (extracted from wxccRoutes for attachments + Webex transcript summaries) -// These centralize Jira interactions so all calls go through the shared client (correct -// cloudId base URL via ex/jira/{cloudId}, Basic auth, and retry policy). -// Uses core /rest/api/3/issue/.../attachments (per working curl) + X-Atlassian-Token: no-check. -// ======================== - -/** - * Shared helper: POST a Buffer as multipart attachment to core Jira attachments endpoint. - * - Uses the configured jiraClient (correct base + auth inherited). - * - Properly spreads form.getHeaders() so boundary is set. - * - Cleans any charset from content-type (prevents 415). - * - Retry loop only around the API call (download must be done by caller). - * - Fail-fast on 401/403 (scope/perms). - */ -async function attachBufferToJira(jiraKey, fileBuffer, fileName) { - for (let attempt = 1; attempt <= 3; attempt++) { - try { - const form = new FormData(); - form.append('file', fileBuffer, fileName); - - const formHeaders = form.getHeaders(); - // Remove charset if present (Atlassian often rejects or causes 415 on multipart+charset) - if (formHeaders['content-type']) { - formHeaders['content-type'] = formHeaders['content-type'].replace(/;\s*charset=[^;]*/i, ''); - } - - // Relative path: jiraClient already has the correct baseURL (ex/jira/{cloudId}) - const uploadPath = `/rest/api/3/issue/${jiraKey}/attachments`; - - await jiraClient.post(uploadPath, form, { - headers: { - 'X-Atlassian-Token': 'no-check', - ...formHeaders - }, - timeout: 15000 - }); - - logger.info('File attached successfully', { jiraKey, fileName, attempt }); - return; - } catch (err) { - const status = err.response?.status; - logger.error('Jira file attach attempt failed', { - jiraKey, - fileName, - attempt, - status, - responseData: err.response?.data, - responseHeaders: err.response?.headers ? Object.fromEntries( - Object.entries(err.response.headers).filter(([k]) => !k.toLowerCase().includes('auth')) - ) : undefined - }); - // Fail fast on auth/permission errors (scope mismatch etc.) - if (status === 401 || status === 403) { - throw err; - } - if (attempt === 3) throw err; - await new Promise(r => setTimeout(r, attempt * 1500)); - } - } -} - -/** - * Download a file from the given URL (S3 pre-signed) *once* and attach using core - * Jira attachments API (/rest/api/3/issue/{key}/attachments). - * Download happens outside the retry loop because the signed URL expires (~1800s). - */ -export async function attachFileToJira(jiraKey, fileUrl, fileName) { - let fileBuffer; - try { - const dl = await downloadClient.get(fileUrl, { - responseType: 'arraybuffer' - }); - fileBuffer = Buffer.from(dl.data); - } catch (dlErr) { - logger.error('Failed to download file from S3 for attachment (URL likely expired on replay)', { - jiraKey, - fileName, - url: fileUrl, - status: dlErr.response?.status, - message: dlErr.message - }); - throw dlErr; - } - - await attachBufferToJira(jiraKey, fileBuffer, fileName); -} - -/** - * Convert a Webex-style JSON transcript to human-readable text. - */ -function formatTranscriptToHumanReadable(data) { - if (!data || !Array.isArray(data.responseContents)) return null; - const lines = []; - lines.push(`Transcript`); - if (data.interactionId) lines.push(`Interaction ID: ${data.interactionId}`); - if (data.languageCode) lines.push(`Language: ${data.languageCode}`); - lines.push(''); - for (const entry of data.responseContents) { - const res = entry.recognitionResult; - if (!res || !res.alternatives || !res.alternatives[0]) continue; - const role = (res.role || 'UNKNOWN').toUpperCase(); - const alt = res.alternatives[0]; - const transcript = (alt.transcript || '').trim(); - if (!transcript) continue; - let ts = ''; - const words = alt.words || []; - if (words.length > 0) { - const start = words[0].start_time || {}; - const totalSec = (start.seconds || 0) + Math.floor((start.nanos || 0) / 1e9); - const min = Math.floor(totalSec / 60); - const sec = Math.floor(totalSec % 60); - ts = `[${String(min).padStart(2,'0')}:${String(sec).padStart(2,'0')}] `; - } - lines.push(`${ts}${role}: ${transcript}`); - } - return lines.join('\n'); -} - -async function fetchAndConvertTranscript(url) { - try { - const resp = await downloadClient.get(url, { timeout: 10000 }); - return formatTranscriptToHumanReadable(resp.data); - } catch (e) { - logger.warn(`Failed to fetch/convert transcript: ${e.message}`); - return null; - } -} - -/** - * Download the JSON transcript, convert to human readable, and attach as *-readable.txt - * (e.g. Transcript_...-readable.txt). Uses shared attach helper for correct headers/retry. - * Failures here are swallowed so they don't mark the original JSON text attach as failed. - */ -export async function attachReadableTranscript(jiraKey, transcriptUrl, originalFileName = null) { - const readable = await fetchAndConvertTranscript(transcriptUrl); - if (!readable) { - logger.warn('Readable transcript conversion yielded no content (check transcript JSON shape or URL)', { jiraKey }); - return false; - } - const base = (originalFileName || `transcript-${jiraKey}`).replace(/\.json$/i, ''); - const fileName = `${base}-readable.txt`; - - try { - await attachBufferToJira(jiraKey, Buffer.from(readable, 'utf8'), fileName); - return true; - } catch (err) { - logger.error('Readable transcript attach failed (non-fatal)', { - jiraKey, - fileName, - error: err.response?.data?.message || err.message, - status: err.response?.status - }); - return false; - } -} - -/** - * Build a clean ADF comment from the Webex CC AI summaries object and post it - * to the Jira issue. The comment is posted with role visibility (restricted/internal) - * so only users with that role see the summary + attachment references. - */ -export async function postWebexSummaryComment(jiraKey, summaries, attachedFiles = []) { - const items = [ - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Initial Contact Reason: ${summaries.intialContactReason || 'N/A'}` }] }] }, - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Additional Context: ${summaries.additionalContext || 'N/A'}` }] }] }, - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Key Actions Taken: ${summaries.keyActionsTake || 'N/A'}` }] }] }, - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Next Steps: ${summaries.nextSteps || 'N/A'}` }] }] }, - { type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Resolution: ${summaries.resolution || 'N/A'}` }] }] } - ]; - - const content = [ - { - type: "heading", - attrs: { level: 3 }, - content: [{ type: "text", text: "Webex Contact Center Summary" }] - }, - { type: "bulletList", content: items }, - { - type: "paragraph", - content: [{ type: "text", text: `Posted via Webex Integration — ${new Date().toISOString()}` }] - } - ]; - - if (Array.isArray(attachedFiles) && attachedFiles.length > 0) { - content.push({ - type: "paragraph", - content: [{ - type: "text", - text: `Attached files (internal): ${attachedFiles.join(', ')}` - }] - }); - } - - const commentPayload = { - body: { - version: 1, - type: "doc", - content - }, - // Restrict visibility so summary + attachment notes are internal only. - // Controlled by JIRA_COMMENT_VISIBILITY_ROLE (or defaults to Administrators). - visibility: { - type: "role", - value: config.jira.commentVisibilityRole || 'Service Desk Team' - } - }; - - await jiraClient.post( - `/rest/api/3/issue/${jiraKey}/comment`, - commentPayload, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - - logger.info('Clean summary comment posted (restricted)', { jiraKey, attachedFiles }); -} - -// ======================== -// Ticket lifecycle: status, update, comment, close -// These are project-agnostic (work for any project the token can see) and use -// the core /rest/api/3/issue/... endpoints. They are separate from the JSM -// Service Desk create flow further below. -// ======================== - -/** - * Convert a plain string to a minimal ADF document (single paragraph). - * ADF is what /rest/api/3/... expects for description/comment bodies. - */ -function plainTextToAdf(text) { - const safe = (text ?? '').toString(); - if (!safe) { - return { version: 1, type: 'doc', content: [] }; - } - // Split on blank lines → separate paragraphs; single newlines become hardBreak. - const paragraphs = safe.split(/\n{2,}/).map(block => { - const parts = block.split('\n'); - const content = []; - parts.forEach((line, idx) => { - if (line.length) content.push({ type: 'text', text: line }); - if (idx < parts.length - 1) content.push({ type: 'hardBreak' }); - }); - return { type: 'paragraph', content }; - }); - return { version: 1, type: 'doc', content: paragraphs }; -} - -/** - * Compact, purpose-built status view — no Grok, no comment enrichment. - * Use this when a caller just wants "where is this ticket right now?". - */ -export async function getTicketStatus(key) { - if (!key || !/^[A-Z]+-\d+$/.test(key)) { - throw new Error(`Invalid ticket key: "${key}"`); - } - - const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,reporter,priority,resolution,created,updated,labels`; - try { - const { data } = await jiraClient.get(url); - const f = data.fields || {}; - return { - key: data.key, - summary: f.summary || null, - status: f.status?.name || null, - statusCategory: f.status?.statusCategory?.key || null, - assignee: f.assignee?.displayName || f.assignee?.emailAddress || null, - reporter: f.reporter?.displayName || f.reporter?.emailAddress || null, - priority: f.priority?.name || null, - resolution: f.resolution?.name || null, - labels: f.labels || [], - created: f.created || null, - updated: f.updated || null - }; - } catch (err) { - logger.error('getTicketStatus failed', { key, status: err.response?.status, details: err.response?.data }); - const e = new Error(`Failed to fetch status for ${key}: ${err.message}`); - e.status = err.response?.status; - e.details = err.response?.data; - throw e; - } -} - -/** - * Partial issue update. Accepts flat, friendly fields: - * { summary, description, priority, labels, assigneeAccountId, additional } - * `additional` is merged raw into the `fields` object (e.g. customfield_* values). - * `description` is a plain string; we convert to ADF. - */ -export async function updateTicket(key, updates = {}) { - if (!key || !/^[A-Z]+-\d+$/.test(key)) { - throw new Error(`Invalid ticket key: "${key}"`); - } - - const fields = {}; - if (updates.summary !== undefined) fields.summary = String(updates.summary); - if (updates.description !== undefined) fields.description = plainTextToAdf(updates.description); - if (updates.priority) fields.priority = { name: String(updates.priority) }; - if (Array.isArray(updates.labels)) fields.labels = updates.labels.map(String); - if (updates.assigneeAccountId) fields.assignee = { accountId: String(updates.assigneeAccountId) }; - if (updates.additional && typeof updates.additional === 'object') Object.assign(fields, updates.additional); - - if (Object.keys(fields).length === 0) { - throw new Error('updateTicket called with no updatable fields'); - } - - try { - await jiraClient.put(`/rest/api/3/issue/${key}`, { fields }, { - headers: { 'Content-Type': 'application/json' } - }); - logger.info('Ticket updated', { key, fieldKeys: Object.keys(fields) }); - return { key, updated: Object.keys(fields) }; - } catch (err) { - logger.error('updateTicket failed', { key, status: err.response?.status, details: err.response?.data }); - const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); - e.status = err.response?.status; - e.details = err.response?.data; - throw e; - } -} - -/** - * Add a comment to an issue. `text` is plain; converted to ADF. - * If `internal: true`, restricts visibility to `config.jira.commentVisibilityRole` - * (same behavior as postWebexSummaryComment). - */ -export async function addComment(key, text, { internal = false } = {}) { - if (!key || !/^[A-Z]+-\d+$/.test(key)) { - throw new Error(`Invalid ticket key: "${key}"`); - } - if (!text || !String(text).trim()) { - throw new Error('Comment text is required'); - } - - const payload = { body: plainTextToAdf(String(text)) }; - if (internal) { - payload.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' }; - } - - try { - const { data } = await jiraClient.post(`/rest/api/3/issue/${key}/comment`, payload, { - headers: { 'Content-Type': 'application/json' } - }); - logger.info('Comment posted', { key, commentId: data?.id, internal }); - return { key, commentId: data?.id, internal }; - } catch (err) { - logger.error('addComment failed', { key, status: err.response?.status, details: err.response?.data }); - const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); - e.status = err.response?.status; - e.details = err.response?.data; - throw e; - } -} - -/** - * Fetch available workflow transitions for an issue. Useful for both the - * client picking a transition manually and for closeTicket() below. - */ -export async function getTransitions(key) { - if (!key || !/^[A-Z]+-\d+$/.test(key)) { - throw new Error(`Invalid ticket key: "${key}"`); - } - try { - const { data } = await jiraClient.get(`/rest/api/3/issue/${key}/transitions`); - return (data.transitions || []).map(t => ({ - id: t.id, - name: t.name, - to: { id: t.to?.id, name: t.to?.name, statusCategory: t.to?.statusCategory?.key }, - hasScreen: !!t.hasScreen - })); - } catch (err) { - logger.error('getTransitions failed', { key, status: err.response?.status, details: err.response?.data }); - throw new Error(`Failed to fetch transitions for ${key}: ${err.message}`); - } -} - -/** - * Execute a specific transition. Optionally set a resolution and/or append a - * comment in the same call (both are fields the transition screen can accept). - */ -export async function transitionTicket(key, transitionId, { resolution, comment, internal = false, additionalFields } = {}) { - if (!key || !/^[A-Z]+-\d+$/.test(key)) { - throw new Error(`Invalid ticket key: "${key}"`); - } - if (!transitionId) throw new Error('transitionId is required'); - - const payload = { transition: { id: String(transitionId) } }; - - const fields = { ...(additionalFields || {}) }; - if (resolution) fields.resolution = { name: String(resolution) }; - if (Object.keys(fields).length) payload.fields = fields; - - if (comment) { - const commentEntry = { add: { body: plainTextToAdf(String(comment)) } }; - if (internal) { - commentEntry.add.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' }; - } - payload.update = { comment: [commentEntry] }; - } - - try { - await jiraClient.post(`/rest/api/3/issue/${key}/transitions`, payload, { - headers: { 'Content-Type': 'application/json' } - }); - logger.info('Ticket transitioned', { key, transitionId, resolution }); - return { key, transitionId, resolution: resolution || null }; - } catch (err) { - logger.error('transitionTicket failed', { - key, transitionId, status: err.response?.status, details: err.response?.data - }); - const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message); - e.status = err.response?.status; - e.details = err.response?.data; - throw e; - } -} - -/** - * Convenience: find a "closing" transition and execute it. - * Prefers explicit `transitionName` if provided, otherwise picks the first - * transition whose target status is in category "done" (Jira's canonical - * category for closed/resolved/completed), falling back to a name-based match. - */ -export async function closeTicket(key, { transitionName, resolution = 'Done', comment, internal = false } = {}) { - const transitions = await getTransitions(key); - if (transitions.length === 0) { - throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`); - } - - let chosen = null; - if (transitionName) { - chosen = transitions.find(t => t.name.toLowerCase() === transitionName.toLowerCase()); - if (!chosen) { - throw new Error(`Transition "${transitionName}" not available for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`); - } - } else { - chosen = transitions.find(t => t.to?.statusCategory === 'done') - || transitions.find(t => /done|closed|resolved|complete/i.test(t.name)); - if (!chosen) { - throw new Error(`Could not find a closing transition for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`); - } - } - - return transitionTicket(key, chosen.id, { resolution, comment, internal }); -} - -// ======================== -// Store Support (SS) Ticket Creation via Service Desk API -// Uses /rest/servicedeskapi/request to create proper JSM customer requests -// with request types. This is separate from corporate tickets (future). -// Consistent base URL from config (supports ex/jira/{cloudId} or direct site). -// ======================== - -const REQUEST_TYPE_MAP = { - // Point of Sale - 'Register Not functioning properly': 269, - 'Unable to login': 275, - 'Business report issue': 267, - 'Broken device / hardware': 266, - - // Hardware - 'Broken Device / Hardware': 266, - 'Report Missing Hardware': 273, - 'Request Additional Hardware': 274, - - // Technology - 'Business Report Issue': 267, - 'Report an Issue with Sterling Application': 272, - 'Omni Turn Off / On': 268, - 'Report a Traffic Counter Issue': 271, - 'Report a Technology issue': 270, - 'UKG Pro / Workforce Management Issues': 426, - 'Store Transportation Request': 493, -}; - -// SubTypes that require a storeNumber. Every currently-supported subType maps -// to a request type whose Store Number field is `required: true` in JSM (see -// ss-fields-*.json). Kept as an explicit set so a future subType that does NOT -// require Store Number can be added by simply omitting it from this set. -const SUBTYPES_REQUIRING_STORE_NUMBER = new Set(Object.keys(REQUEST_TYPE_MAP)); - -/** - * Create a Store Support ticket (JSM request) using the Service Desk API. - * @param {Object} params - * @param {string} params.subType - Exact key from REQUEST_TYPE_MAP (e.g. "Register Not functioning properly") - * @param {string} [params.onBehalfOf] - email or accountId (becomes raiseOnBehalfOf) - * @param {string} params.summary - * @param {string} [params.description] - * @param {string|number} [params.storeNumber] - * @param {Object} [params.additional] - extra customfield_* values to merge into requestFieldValues - */ -export function getSupportedSSSubTypes() { - return Object.keys(REQUEST_TYPE_MAP); -} - -export { REQUEST_TYPE_MAP }; - -/** - * Resolve a store number (e.g. "00305" or 305) to the proper Assets object reference - * for the Store custom field in a JSM request. - * - * This is a "service object" / Assets object and therefore uses the - * api.atlassian.com workspace-scoped endpoint: - * POST https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1/object/aql - * { "qlQuery": "objectTypeId = 109 AND \"Store Number\" = \"00305\"", ... } - * - * References are passed as: - * "customfield_10261": [ { "objectId": "82288" } ] - * - * Workspace ID can be provided via JIRA_ASSETS_WORKSPACE_ID or auto-discovered - * from /rest/servicedeskapi/assets/workspace . - */ -async function getAssetsWorkspaceId() { - if (config.jira.assetsWorkspaceId) { - return config.jira.assetsWorkspaceId; - } - - const list = await listAssetsWorkspacesRaw(); - const first = list.workspaces[0]; - if (first?.workspaceId) { - logger.info(`Discovered Assets workspaceId via /rest/servicedeskapi/assets/workspace: ${first.workspaceId}`); - if (list.workspaces.length > 1) { - logger.warn(`Multiple Assets workspaces are visible to this account (${list.workspaces.length}); using the first. Set JIRA_ASSETS_WORKSPACE_ID explicitly to disambiguate.`, { - workspaces: list.workspaces.map(w => w.workspaceId) - }); - } - return first.workspaceId; - } - - throw new Error('JIRA_ASSETS_WORKSPACE_ID is required for Assets object lookup (or ensure /rest/servicedeskapi/assets/workspace is accessible).'); -} - -/** - * Return the full list of Assets workspaces the current account can see, plus - * the raw payload for diagnostics. Never throws. - */ -async function listAssetsWorkspacesRaw() { - try { - const resp = await jiraClient.get('/rest/servicedeskapi/assets/workspace'); - const data = resp.data; - - let entries = []; - if (Array.isArray(data)) entries = data; - else if (Array.isArray(data?.values)) entries = data.values; - else if (Array.isArray(data?.workspaces)) entries = data.workspaces; - else if (data && typeof data === 'object') entries = [data]; - - const workspaces = entries - .map(e => ({ workspaceId: e.workspaceId || e.id || e.key || e.workspaceID || null })) - .filter(w => w.workspaceId); - - return { httpStatus: resp.status, workspaces, raw: data }; - } catch (e) { - return { - httpStatus: e.response?.status || 'network', - workspaces: [], - raw: e.response?.data || null, - error: e.message - }; - } -} - -/** - * Filter out response headers that would leak scope/tenant/session identifiers - * or noise (cookies, tracing tokens, CORS bookkeeping). We keep just the - * Atlassian informational headers, which are the ones useful for debugging - * unexpected empty results (rate-limit, tracing, deprecation, request id). - */ -function pickInterestingHeaders(headers = {}) { - const wanted = new Set([ - 'content-type', 'content-length', - 'x-request-id', 'x-arequestid', 'x-arequest-id', - 'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset', - 'x-atlassian-request-id', 'x-atlassian-trace-id', - 'atl-traceid', 'atl-request-id', - 'x-atlassian-server-status', 'x-atlassian-cursor', - 'x-content-type-options', 'x-frame-options', - 'deprecation', 'sunset', 'warning', 'retry-after' - ]); - const out = {}; - for (const [k, v] of Object.entries(headers)) { - if (wanted.has(k.toLowerCase())) out[k] = v; - } - return out; -} - -/** - * Low-level AQL POST helper. Never throws on non-2xx. - * Returns { status, statusText, data, headers, requestUrl, requestBody, workspaceId, error }. - */ -async function runAssetsAql(qlQuery, { resultPerPage = 5, includeAttributes = true, extraBody = {} } = {}) { - const workspaceId = await getAssetsWorkspaceId(); - const aqlUrl = `https://api.atlassian.com/jsm/assets/workspace/${workspaceId}/v1/object/aql`; - - const authHeader = jiraClient.defaults.headers.Authorization - || jiraClient.defaults.headers.common?.Authorization; - - const body = { qlQuery, resultPerPage, includeAttributes, ...extraBody }; - - logger.debug('Assets AQL request', { aqlUrl, qlQuery, resultPerPage }); - - try { - const resp = await axios.post(aqlUrl, body, { - headers: { - 'Authorization': authHeader, - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - timeout: 15000, - validateStatus: () => true - }); - return { - status: resp.status, - statusText: resp.statusText, - data: resp.data ?? null, - headers: pickInterestingHeaders(resp.headers || {}), - requestUrl: aqlUrl, - requestBody: body, - workspaceId - }; - } catch (err) { - return { - status: err.response?.status || 'network', - statusText: err.response?.statusText || err.code || 'error', - data: err.response?.data || null, - headers: pickInterestingHeaders(err.response?.headers || {}), - requestUrl: aqlUrl, - requestBody: body, - workspaceId, - error: err.message - }; - } -} - -/** - * Low-level GET helper against api.atlassian.com Assets endpoints. - * Same shape as runAssetsAql. Use for schema/objecttype introspection. - */ -async function runAssetsGet(path) { - const workspaceId = await getAssetsWorkspaceId(); - const url = `https://api.atlassian.com/jsm/assets/workspace/${workspaceId}/v1${path}`; - - const authHeader = jiraClient.defaults.headers.Authorization - || jiraClient.defaults.headers.common?.Authorization; - - try { - const resp = await axios.get(url, { - headers: { 'Authorization': authHeader, 'Accept': 'application/json' }, - timeout: 15000, - validateStatus: () => true - }); - return { - status: resp.status, - statusText: resp.statusText, - data: resp.data ?? null, - headers: pickInterestingHeaders(resp.headers || {}), - requestUrl: url, - workspaceId - }; - } catch (err) { - return { - status: err.response?.status || 'network', - statusText: err.response?.statusText || err.code || 'error', - data: err.response?.data || null, - headers: pickInterestingHeaders(err.response?.headers || {}), - requestUrl: url, - workspaceId, - error: err.message - }; - } -} - -/** - * List all Assets schemas visible to the current token. Critical diagnostic: - * if this returns zero schemas, the token has no Assets access at all - * (regardless of what workspace id is used). - */ -export async function listAssetsSchemas() { - return runAssetsGet('/objectschema/list'); -} - -/** - * Fetch a single Assets object type (id, name, attributes). If HTTP 200, - * the token can see the type — and the attribute names in the response are - * authoritative for AQL queries. - */ -export async function getAssetsObjectType(objectTypeId) { - const [detail, attributes] = await Promise.all([ - runAssetsGet(`/objecttype/${objectTypeId}`), - runAssetsGet(`/objecttype/${objectTypeId}/attributes`) - ]); - return { detail, attributes }; -} - -/** - * Flatten one Assets AQL "value" (object entry) into a compact shape suitable - * for humans debugging attribute names/values. Different Assets tenants return - * subtly different envelopes (attributes[].objectTypeAttribute vs typeAttribute, - * objectAttributeValues[].value vs displayValue), so we're defensive. - */ -function summarizeAssetsObject(obj) { - if (!obj || typeof obj !== 'object') return null; - - const attributes = Array.isArray(obj.attributes) ? obj.attributes.map(attr => { - const meta = attr.objectTypeAttribute || attr.typeAttribute || {}; - const rawValues = Array.isArray(attr.objectAttributeValues) ? attr.objectAttributeValues : []; - const values = rawValues.map(v => v.displayValue ?? v.value ?? v.searchValue ?? null).filter(v => v !== null); - return { - id: attr.objectTypeAttributeId || meta.id || null, - name: meta.name || null, - values - }; - }) : []; - - return { - id: obj.id || null, - objectKey: obj.objectKey || null, - name: obj.label || obj.name || null, - objectType: obj.objectType?.name || null, - objectTypeId: obj.objectType?.id || null, - attributes - }; -} - -async function resolveStoreAssetReference(rawStoreNumber) { - if (!rawStoreNumber) return null; - - const normalized = String(rawStoreNumber).padStart(5, '0'); - const objectTypeId = config.jira.assetsStoreObjectTypeId || '109'; - const attribute = config.jira.assetsStoreNumberAttribute || 'Store Number'; - const attrId = config.jira.assetsStoreNumberAttributeId; - - const queries = [ - `objectTypeId = ${objectTypeId} AND "${attribute}" = "${normalized}"` - ]; - if (attrId) { - queries.push(`objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${normalized}"`); - } - - let lastResult = null; - - for (const qlQuery of queries) { - logger.info('Assets AQL lookup for store number', { qlQuery, storeNumber: normalized }); - - const result = await runAssetsAql(qlQuery, { resultPerPage: 1, includeAttributes: true }); - lastResult = result; - - if (result.status !== 200) { - logger.error('Assets AQL variant failed', { - qlQuery, - status: result.status, - error: result.error, - // data may contain useful "code"/"message" from Atlassian; safe to log - atlassianError: result.data - }); - continue; - } - - const data = result.data || {}; - const values = Array.isArray(data.values) ? data.values : []; - const total = typeof data.total === 'number' ? data.total : values.length; - - if (total === 0 || values.length === 0) { - logger.warn('Assets AQL returned zero results for variant', { qlQuery, total, storeNumber: normalized }); - continue; - } - - const objectId = extractObjectIdFromResponse(data); - if (!objectId) { - logger.warn('Assets AQL returned results but no extractable id', { - qlQuery, - storeNumber: normalized, - valuesSample: values[0] - }); - continue; - } - - logger.info('Resolved store to Assets object', { storeNumber: normalized, objectId: String(objectId) }); - return [{ objectId: String(objectId) }]; - } - - const total = lastResult?.data?.total ?? 'unknown'; - throw new Error( - `Failed to resolve Store Number ${normalized} via Assets (objectTypeId=${objectTypeId}). ` + - `No results or unparseable id across all variants. Last total=${total}` - ); -} - -/** - * Diagnostic helper: run several AQL variants for a given store number and - * return each result side-by-side, plus workspace/schema/object-type - * introspection and a plain-English diagnosis. Intended for a dev-only debug - * endpoint. - */ -export async function probeAssetsForStore(rawStoreNumber, { extraVariants = [] } = {}) { - const raw = String(rawStoreNumber ?? '').trim(); - const padded = raw ? raw.padStart(5, '0') : ''; - const unpadded = raw.replace(/^0+/, '') || raw; - - const objectTypeId = config.jira.assetsStoreObjectTypeId || '109'; - const schemaId = config.jira.assetsStoreSchemaId; - const attribute = config.jira.assetsStoreNumberAttribute || 'Store Number'; - const attrId = config.jira.assetsStoreNumberAttributeId; - - // -------- Introspection -------- - - // A. Workspace discovery — did we get one at all? What are ALL visible workspaces? - const workspacesList = await listAssetsWorkspacesRaw(); - let workspaceIdResolved = null; - let workspaceIdError = null; - try { - workspaceIdResolved = await getAssetsWorkspaceId(); - } catch (e) { - workspaceIdError = e.message; - } - - // B. Schemas the token can actually see. If empty, permissions are the issue. - let schemasProbe = null; - if (workspaceIdResolved) { - const schemasResp = await listAssetsSchemas(); - let visibleSchemas = []; - const data = schemasResp.data; - // Response shape varies: sometimes an array, sometimes { values: [...] }, sometimes { objectschemas: [...] } - const list = Array.isArray(data) ? data - : Array.isArray(data?.values) ? data.values - : Array.isArray(data?.objectschemas) ? data.objectschemas - : Array.isArray(data?.objectSchemas) ? data.objectSchemas - : []; - visibleSchemas = list.map(s => ({ - id: s.id ?? null, - name: s.name ?? null, - objectSchemaKey: s.objectSchemaKey ?? s.key ?? null - })); - schemasProbe = { - httpStatus: schemasResp.status, - requestUrl: schemasResp.requestUrl, - count: visibleSchemas.length, - schemas: visibleSchemas, - raw: schemasResp.status === 200 ? undefined : schemasResp.data, - headers: schemasResp.headers - }; - } - - // C. Object type detail — is object type 109 visible? What are its attributes actually called? - let objectTypeProbe = null; - if (workspaceIdResolved) { - const { detail, attributes } = await getAssetsObjectType(objectTypeId); - const attrList = Array.isArray(attributes.data) ? attributes.data - : Array.isArray(attributes.data?.values) ? attributes.data.values - : []; - objectTypeProbe = { - detail: { - httpStatus: detail.status, - requestUrl: detail.requestUrl, - name: detail.data?.name ?? null, - objectSchemaId: detail.data?.objectSchemaId ?? null, - raw: detail.status === 200 ? { id: detail.data?.id, name: detail.data?.name, objectSchemaId: detail.data?.objectSchemaId, description: detail.data?.description } : detail.data, - headers: detail.headers - }, - attributes: { - httpStatus: attributes.status, - requestUrl: attributes.requestUrl, - count: attrList.length, - names: attrList.map(a => ({ - id: a.id ?? null, - name: a.name ?? null, - type: a.type ?? a.defaultType?.name ?? null, - system: a.system ?? null - })), - raw: attributes.status === 200 ? undefined : attributes.data, - headers: attributes.headers - } - }; - } - - // -------- AQL variants -------- - - const variants = []; - - if (schemaId) { - variants.push({ label: 'schema_probe', qlQuery: `objectSchemaId = ${schemaId}`, resultPerPage: 5 }); - } - variants.push({ label: 'object_type_probe', qlQuery: `objectTypeId = ${objectTypeId}`, resultPerPage: 5 }); - variants.push({ label: 'object_type_by_name', qlQuery: `objectType = "Store"`, resultPerPage: 5 }); - - if (raw) { - variants.push({ label: 'attr_name_padded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" = "${padded}"`, resultPerPage: 3 }); - variants.push({ label: 'attr_name_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" = "${unpadded}"`, resultPerPage: 3 }); - variants.push({ label: 'attr_name_like_padded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" LIKE "${padded}"`, resultPerPage: 3 }); - - variants.push({ label: 'name_padded', qlQuery: `objectTypeId = ${objectTypeId} AND Name = "${padded}"`, resultPerPage: 3 }); - variants.push({ label: 'name_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND Name = "${unpadded}"`, resultPerPage: 3 }); - variants.push({ label: 'name_like_padded', qlQuery: `objectTypeId = ${objectTypeId} AND Name LIKE "${padded}"`, resultPerPage: 3 }); - - // Schema-only variants (no objectTypeId filter) in case the type filter is what's dropping results. - if (schemaId) { - variants.push({ label: 'schema_name_padded', qlQuery: `objectSchemaId = ${schemaId} AND Name = "${padded}"`, resultPerPage: 3 }); - variants.push({ label: 'schema_name_unpadded', qlQuery: `objectSchemaId = ${schemaId} AND Name = "${unpadded}"`, resultPerPage: 3 }); - } - - if (attrId) { - variants.push({ label: 'attr_id_padded', qlQuery: `objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${padded}"`, resultPerPage: 3 }); - variants.push({ label: 'attr_id_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${unpadded}"`, resultPerPage: 3 }); - } - } - - for (const v of extraVariants) { - variants.push({ label: v.label || 'custom', qlQuery: v.qlQuery, resultPerPage: v.resultPerPage ?? 5 }); - } - - const results = []; - for (const v of variants) { - const r = await runAssetsAql(v.qlQuery, { resultPerPage: v.resultPerPage, includeAttributes: true }); - const values = Array.isArray(r.data?.values) ? r.data.values : []; - results.push({ - variant: v.label, - qlQuery: v.qlQuery, - httpStatus: r.status, - statusText: r.statusText, - total: r.data?.total ?? values.length, - objects: values.map(summarizeAssetsObject), - atlassianError: r.status === 200 ? undefined : r.data, - headers: r.headers, - // Truncated raw body so we can see *everything* Atlassian sent back - // (some tenants surface hints in "hasMoreResults", "objectTypeAttributes", etc.) - rawBody: r.data && typeof r.data === 'object' - ? JSON.parse(JSON.stringify(r.data)) // deep copy so we don't mutate - : r.data - }); - } - - // -------- Diagnosis -------- - - const diagnosis = buildAssetsProbeDiagnosis({ - workspaceIdResolved, - workspaceIdError, - workspacesList, - schemasProbe, - objectTypeProbe, - variantResults: results, - configuredAttribute: attribute, - configuredAttributeId: attrId, - configuredSchemaId: schemaId, - configuredObjectTypeId: objectTypeId, - jiraEmail: config.jira.email - }); - - return { - input: { raw, padded, unpadded }, - config: { - workspaceId: workspaceIdResolved || `error: ${workspaceIdError}`, - objectTypeId, - schemaId: schemaId || null, - attribute, - attrId: attrId || null, - storeCustomFieldId: config.jira.storeCustomFieldId || 'customfield_10261', - authType: config.jira.authType, - jiraEmail: config.jira.email ? maskEmail(config.jira.email) : null - }, - workspace: { - resolvedId: workspaceIdResolved, - error: workspaceIdError, - allVisible: workspacesList.workspaces, - httpStatus: workspacesList.httpStatus - }, - schemas: schemasProbe, - objectType: objectTypeProbe, - variants: results, - diagnosis - }; -} - -function maskEmail(email) { - if (!email || !email.includes('@')) return email || null; - const [local, domain] = email.split('@'); - const shown = local.length <= 3 ? local[0] : `${local.slice(0, 3)}…`; - return `${shown}@${domain}`; -} - -function buildAssetsProbeDiagnosis({ - workspaceIdResolved, - workspaceIdError, - workspacesList, - schemasProbe, - objectTypeProbe, - variantResults, - configuredAttribute, - configuredAttributeId, - configuredSchemaId, - configuredObjectTypeId, - jiraEmail -}) { - const notes = []; - const suggestions = []; - let likelyCause = 'unknown'; - - const visibleWorkspaces = workspacesList?.workspaces || []; - const email = jiraEmail || '(JIRA_EMAIL)'; - - if (!workspaceIdResolved) { - likelyCause = 'workspace_not_discovered'; - notes.push(`Could not discover Assets workspace id (${workspaceIdError}).`); - suggestions.push('Set JIRA_ASSETS_WORKSPACE_ID explicitly, or ensure /rest/servicedeskapi/assets/workspace is reachable.'); - return { likelyCause, notes, suggestions }; - } - - if (visibleWorkspaces.length > 1) { - notes.push(`Account can see ${visibleWorkspaces.length} Assets workspaces: ${visibleWorkspaces.map(w => w.workspaceId).join(', ')}. Using ${workspaceIdResolved}.`); - suggestions.push('If the Store schema lives in a different workspace, set JIRA_ASSETS_WORKSPACE_ID explicitly.'); - } - - const schemaHttp = schemasProbe?.httpStatus; - const schemaCount = schemasProbe?.count ?? 0; - - if (schemaHttp && schemaHttp !== 200) { - likelyCause = 'schema_list_error'; - notes.push(`GET /objectschema/list returned HTTP ${schemaHttp}. The token cannot list schemas.`); - if (schemaHttp === 401 || schemaHttp === 403) { - suggestions.push(`Add ${email} to an Object Schema role on the target schema in Jira → Assets → Object schemas → Configure → Roles. In Assets, API-token scopes (read:cmdb-*:jira) are NOT sufficient on their own; the user still needs schema-level role membership.`); - } - return { likelyCause, notes, suggestions }; - } - - if (schemaCount === 0) { - likelyCause = 'no_schema_visibility'; - notes.push('GET /objectschema/list returned HTTP 200 with 0 schemas — this account has no visibility to any Assets schema, so every AQL against it returns total=0.'); - suggestions.push(`Ask a Jira Assets admin to add ${email} to a role on the Store schema in Jira → Assets → Object schemas → Configure → Roles. "Object Schema Viewer" or "Object Schema User" is enough to look up store objects; "Developer" is needed to create/update objects.`); - suggestions.push('The four read:cmdb-* / write:cmdb-* scopes on the token are necessary but not sufficient — Assets enforces a separate per-schema role check on top of the OAuth scopes.'); - return { likelyCause, notes, suggestions }; - } - - const visibleSchemaIds = (schemasProbe?.schemas || []).map(s => String(s.id)); - const visibleSchemaSummary = (schemasProbe?.schemas || []).map(s => `${s.id}:${s.name}`).join(', '); - notes.push(`Account can see ${schemaCount} schema(s): ${visibleSchemaSummary}.`); - - if (configuredSchemaId && !visibleSchemaIds.includes(String(configuredSchemaId))) { - likelyCause = 'schema_not_visible'; - notes.push(`Configured JIRA_ASSETS_STORE_SCHEMA_ID=${configuredSchemaId} is NOT in the list of schemas this account can see. AQL against schema ${configuredSchemaId} will always return total=0.`); - suggestions.push(`Ask a Jira Assets admin to add ${email} to a role on the Store schema (id ${configuredSchemaId}) in Jira → Assets → Object schemas → Configure → Roles. "Object Schema Viewer" is enough for read; "Developer" for writes.`); - suggestions.push('Reminder: Jira Assets enforces per-schema role membership on top of OAuth scopes. Granting the token the read:cmdb-* / write:cmdb-* scopes is necessary but NOT sufficient — the underlying user must also be in a role on the schema.'); - if (visibleSchemaIds.length === 1) { - suggestions.push(`Right now the account is only in a role on schema ${visibleSchemaIds[0]} (${visibleSchemaSummary}). Same admin action needs to happen for the Store schema.`); - } - return { likelyCause, notes, suggestions }; - } - - const otDetailStatus = objectTypeProbe?.detail?.httpStatus; - const otAttrStatus = objectTypeProbe?.attributes?.httpStatus; - - if (otDetailStatus && otDetailStatus !== 200) { - if (otDetailStatus === 403) { - likelyCause = 'object_type_forbidden'; - notes.push(`GET /objecttype/${configuredObjectTypeId} returned HTTP 403. The account can list schemas but cannot see this object type — almost always because it is missing an Object Schema role on the Store schema.`); - suggestions.push(`Add ${email} to an Object Schema role on the Store schema in Jira → Assets → Object schemas → Configure → Roles.`); - } else { - likelyCause = 'object_type_not_visible'; - notes.push(`GET /objecttype/${configuredObjectTypeId} returned HTTP ${otDetailStatus}. The configured objectTypeId is either wrong or not visible to this account.`); - suggestions.push(`Verify JIRA_ASSETS_STORE_OBJECT_TYPE_ID matches the actual Store type id in Jira Assets. Note the .env has a typo: IRA_ASSETS_STORE_OBJECT_TYPE_ID (missing leading J) — the app currently defaults to 109.`); - } - return { likelyCause, notes, suggestions }; - } - - if (otDetailStatus === 200) { - notes.push(`Object type is visible: ${objectTypeProbe.detail.name} (schema ${objectTypeProbe.detail.objectSchemaId}).`); - } - - if (otAttrStatus === 200 && objectTypeProbe.attributes.count > 0) { - const attrNames = objectTypeProbe.attributes.names.map(a => a.name).filter(Boolean); - const attrMatch = attrNames.find(n => n.toLowerCase() === (configuredAttribute || '').toLowerCase()); - if (!attrMatch) { - likelyCause = 'attribute_name_mismatch'; - notes.push(`The configured attribute "${configuredAttribute}" is NOT among the object type's attributes. Actual attribute names: ${attrNames.join(', ')}.`); - const guess = attrNames.find(n => /store|number|store\s*id/i.test(n)); - if (guess) suggestions.push(`Set JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE="${guess}" (or use the id form via JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE_ID).`); - else suggestions.push('Pick the correct attribute from the list above and set JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE (or ...ATTRIBUTE_ID) accordingly.'); - return { likelyCause, notes, suggestions }; - } - notes.push(`Attribute "${configuredAttribute}" exists on the object type.`); - } - - const anyHits = variantResults.some(v => v.total > 0); - if (!anyHits) { - likelyCause = 'value_format_mismatch'; - notes.push('Schema and object type are visible but no AQL variant returned rows. Attribute values are likely stored in a form none of the variants matched.'); - suggestions.push('Re-run the probe without a storeNumber to sample real Store objects: curl "http://localhost:1866/api/wxccai/debug/assetsProbe" — the object_type_probe row will show up to 5 real Store objects with their actual attribute values, so you can see how Store Number is stored (leading zeros, prefix, etc.).'); - } else { - const winners = variantResults.filter(v => v.total > 0).map(v => v.variant); - likelyCause = 'success'; - notes.push(`These variants returned rows: ${winners.join(', ')}. Lock resolveStoreAssetReference to the first one.`); - } - - return { likelyCause, notes, suggestions }; -} - -// Helper kept outside the loop -function extractObjectIdFromResponse(respData) { - if (!respData || typeof respData !== 'object') return null; - - if (respData.id) return respData.id; - if (respData.objectId) return respData.objectId; - - const listKeys = ['values', 'objectEntries', 'objects', 'objectList', 'results', 'items']; - for (const key of listKeys) { - const list = respData[key]; - if (Array.isArray(list)) { - for (const item of list) { - if (item && typeof item === 'object') { - if (item.id) return item.id; - if (item.objectId) return item.objectId; - if (item.object && item.object.id) return item.object.id; - if (item.attributes && item.attributes.id) return item.attributes.id; - } - } - } - } - - function deepFind(obj, depth = 0) { - if (depth > 6 || obj == null || typeof obj !== 'object') return null; - if (obj.id && (typeof obj.id === 'string' || typeof obj.id === 'number')) return obj.id; - if (obj.objectId && (typeof obj.objectId === 'string' || typeof obj.objectId === 'number')) return obj.objectId; - if (Array.isArray(obj)) { - for (const el of obj) { - const found = deepFind(el, depth + 1); - if (found) return found; - } - } else { - for (const k of Object.keys(obj)) { - const found = deepFind(obj[k], depth + 1); - if (found) return found; - } - } - return null; - } - - return deepFind(respData); -} - -export async function createSSRequest(params = {}) { - const { - subType, - onBehalfOf, - summary, - description, - storeNumber, - additional = {} - } = params; - - if (!subType || !summary) { - const err = new Error('subType and summary are required'); - err.status = 400; - throw err; - } - - const requestTypeId = REQUEST_TYPE_MAP[subType]; - if (!requestTypeId) { - const err = new Error(`Unknown subType: "${subType}". Must be one of the supported values.`); - err.status = 400; - throw err; - } - - // Fail-fast: every current subType requires Store Number. Catching this - // client-side gives a clean API error instead of forwarding to Jira and - // getting back an opaque "Please provide a value for required field - // 'Store Number'" that references Jira internals. - const normalizedStoreNumber = storeNumber != null && String(storeNumber).trim() !== '' - ? String(storeNumber).trim() - : null; - if (SUBTYPES_REQUIRING_STORE_NUMBER.has(subType) && !normalizedStoreNumber) { - const err = new Error(`storeNumber is required for subType "${subType}"`); - err.status = 400; - throw err; - } - - const serviceDeskId = config.jira.serviceDeskId || '170'; - - // Build requestFieldValues. Store Number is special because it is an Assets object. - const storeCustomField = config.jira.storeCustomFieldId || 'customfield_10261'; - - const requestFieldValues = { - summary, - description: description || summary, - ...additional - }; - - if (normalizedStoreNumber) { - // Resolve to proper Assets object reference: [ { "objectId": "82288" } ] - const storeRef = await resolveStoreAssetReference(normalizedStoreNumber); - if (storeRef) { - requestFieldValues[storeCustomField] = storeRef; - } - } - - const payload = { - serviceDeskId: String(serviceDeskId), - requestTypeId: String(requestTypeId), - requestFieldValues - }; - - if (onBehalfOf) { - payload.raiseOnBehalfOf = onBehalfOf; - } - - try { - const response = await jiraClient.post( - '/rest/servicedeskapi/request', - payload, - { - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - } - ); - - const data = response.data; - logger.info('SS ticket created successfully', { - issueKey: data?.issueKey, - subType, - storeNumber: String(storeNumber || '').padStart(5, '0') - }); - return data; - } catch (err) { - const errData = err.response?.data || {}; - const message = errData.errorMessage || errData.message || err.message || 'Unknown error creating SS request'; - logger.error('Failed to create SS request', { - subType, - storeNumber, - status: err.response?.status, - details: errData - }); - const error = new Error(message); - error.status = err.response?.status; - error.details = errData; - throw error; - } -} - -// Export everything export default { fetchJiraIssue, fetchPlainDescription, @@ -1463,5 +98,5 @@ export default { getTransitions, transitionTicket, closeTicket, - jiraClient // export the client so other files can use it + jiraClient, };