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'; // 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, fetchPublicComments, searchOpenTicketsByReporterEmail, attachFileToJira, attachReadableTranscript, postWebexSummaryComment, createSSRequest, getSupportedSSSubTypes, probeAssetsForStore, getTicketStatus, updateTicket, addComment, getTransitions, transitionTicket, closeTicket, jiraClient // export the client so other files can use it };