- New SUBTYPES_REQUIRING_STORE_NUMBER set (seeded with every current
subType, all of which mark Store Number required in JSM). Adding a
future subType that does NOT require Store Number is a one-line omit.
- createSSRequest now trims storeNumber (rejects whitespace-only) and
throws a 400 with a clear message ("storeNumber is required for
subType ...") before making any Jira API call.
- Validation errors now carry err.status = 400 so future non-route
callers can distinguish client errors from Jira failures.
Co-authored-by: Cursor <cursoragent@cursor.com>
1467 lines
58 KiB
JavaScript
1467 lines
58 KiB
JavaScript
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
|
|
};
|