Compare commits
6 commits
cursor/ini
...
cursor/sto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4bc6462a3 | ||
|
|
66255a7b0c | ||
|
|
fa06538aa4 | ||
|
|
b8f2eab4ab | ||
|
|
312448f597 | ||
|
|
c4a0a6934e |
16 changed files with 2354 additions and 1420 deletions
29
.env.example
29
.env.example
|
|
@ -45,6 +45,35 @@ JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE_ID=
|
|||
# The custom field on the JSM request that holds the Store Assets reference.
|
||||
JIRA_STORE_CUSTOM_FIELD_ID=customfield_10261
|
||||
|
||||
# --- Assets Stores cache (personal-PAT sync workaround) ---
|
||||
# The service account is silently filtered out of Object Type 109 (see
|
||||
# Forgejo issue #1). Until that's fixed, the app populates a local store-number
|
||||
# -> objectId cache using a *personal* Atlassian PAT that has the right role.
|
||||
# This PAT is used ONLY for reading the Stores schema; nothing that mutates
|
||||
# Jira state uses it.
|
||||
#
|
||||
# The .env file is the primary supported storage for the PAT (the bot runs on
|
||||
# a Linux host; macOS Keychain isn't available there). Because .env stays out
|
||||
# of source control (.gitignore) and app.log no longer echoes auth headers,
|
||||
# the cleartext token here is scoped to whoever has filesystem access on the
|
||||
# deploy host — lock the file down with `chmod 600 .env` and rotate the token
|
||||
# if that trust changes.
|
||||
#
|
||||
# For local dev on macOS you can instead source the token from Keychain via
|
||||
# bin/load-assets-sync-secret.sh (see README) and leave ASSETS_SYNC_TOKEN out
|
||||
# of .env entirely.
|
||||
ASSETS_SYNC_EMAIL=you@ae.com
|
||||
ASSETS_SYNC_TOKEN=REPLACE_ME
|
||||
|
||||
# Where the local cache lives on disk (JSON). Gitignored. Regenerable via
|
||||
# POST /api/wxccai/admin/storesCache/refresh.
|
||||
STORES_CACHE_PATH=./data/stores.json
|
||||
# How often to run a full resync (hours). 0 disables the scheduler.
|
||||
STORES_CACHE_REFRESH_HOURS=24
|
||||
# Cache is considered "stale" after this many hours; boot-time refresh fires
|
||||
# if the on-disk snapshot is older than this.
|
||||
STORES_CACHE_STALE_AFTER_HOURS=48
|
||||
|
||||
# --- xAI (Grok) ---
|
||||
XAI_API_KEY=REPLACE_ME
|
||||
XAI_BASE_URL=https://api.x.ai/v1
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -35,6 +35,11 @@ coverage/
|
|||
tmp/
|
||||
.tmp/
|
||||
|
||||
# Stores cache and any other locally-generated app state.
|
||||
# Never contains secrets, but is machine-specific and can be regenerated
|
||||
# from Assets via POST /api/wxccai/admin/storesCache/refresh.
|
||||
data/
|
||||
|
||||
# =============================================
|
||||
# Editor / IDE / OS
|
||||
# =============================================
|
||||
|
|
|
|||
60
README.md
60
README.md
|
|
@ -55,10 +55,70 @@ Base path: `/api/wxccai`.
|
|||
|
||||
- `POST /issueTranscript/:jiraKey` — attaches audio + JSON transcript + human-readable transcript, then posts a restricted-visibility summary comment.
|
||||
|
||||
### Admin
|
||||
|
||||
- `GET /admin/storesCache/status` — snapshot of the local Assets store cache: `{ storeCount, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }`. Safe for health checks.
|
||||
- `POST /admin/storesCache/refresh` — force an immediate resync via the personal PAT (see below). Takes a few seconds and returns the new status.
|
||||
|
||||
### Debug (non-production only)
|
||||
|
||||
- `GET /debug/assetsProbe?storeNumber=305` — runs several AQL variants against Jira Assets and returns visible schemas + object-type detail + a computed diagnosis. Returns 404 when `NODE_ENV=production`.
|
||||
|
||||
## Stores cache (Assets workaround)
|
||||
|
||||
`createSSRequest` needs to translate a store number into an Assets object id. The shared service account is silently filtered out of the Store object type, so the app maintains a local `storeNumber → objectId` cache that's populated from a **personal Atlassian PAT** (a real human account with the right Assets role). The service account is still used for everything else (creating tickets, comments, attachments).
|
||||
|
||||
Both storage patterns end up in the same place — `process.env.ASSETS_SYNC_TOKEN` — so the runtime code path is identical. Pick whichever fits the host.
|
||||
|
||||
### Setup A — production / Linux host (`.env`)
|
||||
|
||||
Put the values directly in `.env` (which is gitignored) and lock the file down:
|
||||
|
||||
```bash
|
||||
cat >> .env <<'EOF'
|
||||
ASSETS_SYNC_EMAIL=you@ae.com
|
||||
ASSETS_SYNC_TOKEN=<paste-your-atlassian-api-token>
|
||||
EOF
|
||||
|
||||
chmod 600 .env # only the bot user can read it
|
||||
```
|
||||
|
||||
Then start normally:
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
Rotate the token in Atlassian → Account → Security → API tokens whenever the trust boundary on the host changes (new operator, offboarding, suspected leak). The app reloads it on the next process start.
|
||||
|
||||
### Setup B — local dev on macOS (Keychain)
|
||||
|
||||
If you're running the app on a Mac and would rather not keep the PAT in `.env`, use the wrapper script — it pulls the token from Keychain into `ASSETS_SYNC_TOKEN` before exec'ing the process:
|
||||
|
||||
```bash
|
||||
security add-generic-password \
|
||||
-s jira-assets-sync \
|
||||
-a you@ae.com \
|
||||
-w '<paste-your-atlassian-api-token>' \
|
||||
-U
|
||||
|
||||
echo 'ASSETS_SYNC_EMAIL=you@ae.com' >> .env # email in .env, token stays in Keychain
|
||||
|
||||
./bin/load-assets-sync-secret.sh npm start
|
||||
```
|
||||
|
||||
If `ASSETS_SYNC_TOKEN` is already in the process env (Setup A above), the wrapper is a no-op and skips the Keychain lookup.
|
||||
|
||||
### Runtime behavior
|
||||
|
||||
On boot the app loads the on-disk cache at `data/stores.json`, kicks off a background refresh if the snapshot is missing or older than `STORES_CACHE_STALE_AFTER_HOURS`, and schedules a periodic full resync every `STORES_CACHE_REFRESH_HOURS`. `resolveStoreAssetReference` then serves lookups from memory (sub-ms) with a live PAT lookup as fallback for brand-new stores.
|
||||
|
||||
Force a refresh at any time:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:1866/api/wxccai/admin/storesCache/refresh
|
||||
```
|
||||
|
||||
## Jira Assets gotcha
|
||||
|
||||
`createSSRequest` resolves a store number to an Assets object reference (`customfield_10261`). Two independent permission layers must both grant access, or every AQL query silently returns `total: 0`:
|
||||
|
|
|
|||
78
bin/load-assets-sync-secret.sh
Executable file
78
bin/load-assets-sync-secret.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env sh
|
||||
# -----------------------------------------------------------------------------
|
||||
# load-assets-sync-secret.sh
|
||||
#
|
||||
# Loads the personal Atlassian PAT used for the Assets → Stores cache sync
|
||||
# from macOS Keychain into ASSETS_SYNC_TOKEN, then execs whatever command you
|
||||
# passed as arguments.
|
||||
#
|
||||
# Rationale:
|
||||
# The service account is silently filtered out of Object Type 109 (Store
|
||||
# Address / Hierarchy) — see Forgejo issue #1. The stores cache is populated
|
||||
# using a personal PAT instead. On a shared dev Mac, Keychain is a nicer home
|
||||
# for that PAT than .env (encrypted at rest, per-user access). On the prod
|
||||
# Linux host where the bot actually runs, put the PAT in .env directly with
|
||||
# `chmod 600 .env` — this script exits as a no-op if ASSETS_SYNC_TOKEN is
|
||||
# already exported into the process env.
|
||||
#
|
||||
# Setup (one-time, per machine):
|
||||
#
|
||||
# # 1. Store the token in Keychain
|
||||
# security add-generic-password \
|
||||
# -s jira-assets-sync \
|
||||
# -a mcqueenj@ae.com \
|
||||
# -w '<paste-your-atlassian-api-token-here>' \
|
||||
# -U
|
||||
#
|
||||
# # 2. Set the email (either exported here or in ~/.zshrc)
|
||||
# export ASSETS_SYNC_EMAIL="mcqueenj@ae.com"
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# ./bin/load-assets-sync-secret.sh npm start
|
||||
# ./bin/load-assets-sync-secret.sh node src/app.js
|
||||
#
|
||||
# Environment variables (override defaults if needed):
|
||||
#
|
||||
# ASSETS_SYNC_KEYCHAIN_SERVICE Keychain service name (default: jira-assets-sync)
|
||||
# ASSETS_SYNC_KEYCHAIN_ACCOUNT Keychain account name (default: value of $ASSETS_SYNC_EMAIL)
|
||||
# ASSETS_SYNC_TOKEN If already set, skip the Keychain read entirely.
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
set -eu
|
||||
|
||||
if [ -z "${ASSETS_SYNC_TOKEN:-}" ]; then
|
||||
SERVICE="${ASSETS_SYNC_KEYCHAIN_SERVICE:-jira-assets-sync}"
|
||||
ACCOUNT="${ASSETS_SYNC_KEYCHAIN_ACCOUNT:-${ASSETS_SYNC_EMAIL:-}}"
|
||||
|
||||
if [ -z "$ACCOUNT" ]; then
|
||||
printf 'load-assets-sync-secret.sh: neither ASSETS_SYNC_TOKEN nor an account name is set.\n' >&2
|
||||
printf ' Export ASSETS_SYNC_EMAIL=you@ae.com or ASSETS_SYNC_KEYCHAIN_ACCOUNT=<account>.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v security >/dev/null 2>&1; then
|
||||
printf 'load-assets-sync-secret.sh: `security` not found (this script is macOS-only).\n' >&2
|
||||
printf ' On Linux/prod, export ASSETS_SYNC_TOKEN directly from your secret manager.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! ASSETS_SYNC_TOKEN=$(security find-generic-password -s "$SERVICE" -a "$ACCOUNT" -w 2>/dev/null); then
|
||||
printf 'load-assets-sync-secret.sh: Keychain lookup failed for service="%s" account="%s".\n' "$SERVICE" "$ACCOUNT" >&2
|
||||
printf ' Store the token with:\n' >&2
|
||||
printf ' security add-generic-password -s %s -a %s -w '\''<token>'\'' -U\n' "$SERVICE" "$ACCOUNT" >&2
|
||||
exit 1
|
||||
fi
|
||||
export ASSETS_SYNC_TOKEN
|
||||
fi
|
||||
|
||||
# Only echoes existence, never the token itself.
|
||||
printf 'load-assets-sync-secret.sh: ASSETS_SYNC_TOKEN loaded (%d chars) for %s\n' \
|
||||
"${#ASSETS_SYNC_TOKEN}" "${ASSETS_SYNC_EMAIL:-<unset ASSETS_SYNC_EMAIL>}" >&2
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
printf 'load-assets-sync-secret.sh: no command given; exiting after loading token.\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
|
|
@ -2,6 +2,7 @@ import express from 'express';
|
|||
import config from './config/index.js';
|
||||
import wxccRoutes from './routes/wxccRoutes.js';
|
||||
import { getDetailedHealth } from './services/healthService.js';
|
||||
import * as storesCache from './services/jira/storesCache.js';
|
||||
import logger from './utilities/logger.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -106,6 +107,8 @@ app.get('/', (req, res) => {
|
|||
closeTicket: "POST /api/wxccai/ticket/:key/close body: { transitionName?, resolution?, comment?, internal? }",
|
||||
createSSRequest: "POST /api/wxccai/createSSRequest (store support ticket creator; storeNumber auto-resolved via Assets)",
|
||||
ssRequestTypes: "GET /api/wxccai/ssRequestTypes (lists exact subType values + config notes)",
|
||||
storesCacheStatus: "GET /api/wxccai/admin/storesCache/status (Assets store cache state)",
|
||||
storesCacheRefresh: "POST /api/wxccai/admin/storesCache/refresh (force a resync via personal PAT)",
|
||||
assetsProbe: "GET /api/wxccai/debug/assetsProbe?storeNumber=305 (non-production only)"
|
||||
}
|
||||
});
|
||||
|
|
@ -131,4 +134,10 @@ app.use((req, res) => {
|
|||
|
||||
app.listen(config.port, () => {
|
||||
logger.info(`Server running on port ${config.port} in ${config.nodeEnv} mode`);
|
||||
|
||||
// Kick off the stores cache: load from disk, schedule periodic refresh,
|
||||
// and (if stale) start a background full refresh. Never blocks startup.
|
||||
storesCache.init().catch(err => {
|
||||
logger.error('storesCache.init failed at boot', { error: err.message });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,23 @@
|
|||
import dotenv from 'dotenv';
|
||||
import path from 'node:path';
|
||||
dotenv.config();
|
||||
|
||||
const cloudId = process.env.JIRA_CLOUD_ID?.trim();
|
||||
let baseUrl = process.env.JIRA_BASE_URL?.trim();
|
||||
|
||||
const intFromEnv = (name, fallback) => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === null || raw === '') return fallback;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
const boolFromEnv = (name, fallback) => {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw === null || raw === '') return fallback;
|
||||
return /^(1|true|yes|on)$/i.test(String(raw).trim());
|
||||
};
|
||||
|
||||
if (cloudId) {
|
||||
// When JIRA_CLOUD_ID is provided, use the api.atlassian.com/ex/jira gateway form.
|
||||
// This is the current style for addressing a specific Jira Cloud site (e.g. for
|
||||
|
|
@ -56,6 +70,34 @@ export const config = {
|
|||
|
||||
// The custom field ID on the request that holds the Store Assets reference
|
||||
storeCustomFieldId: process.env.JIRA_STORE_CUSTOM_FIELD_ID || 'customfield_10261',
|
||||
|
||||
// --- Personal-account credentials for the Stores cache sync ---
|
||||
// Compartmentalized from the service account above. Used ONLY by
|
||||
// src/services/jira/assetsSyncClient.js to populate storesCache. This
|
||||
// exists because the service account is silently filtered out for the
|
||||
// Store object type (see Forgejo issue #1 for the underlying
|
||||
// permission problem). Never used for anything that mutates state on
|
||||
// Atlassian's side.
|
||||
//
|
||||
// The token is expected to be exported into the process env from macOS
|
||||
// Keychain via bin/load-assets-sync-secret.sh — it should NOT sit in
|
||||
// .env in cleartext.
|
||||
assetsSyncEmail: process.env.ASSETS_SYNC_EMAIL?.trim() || null,
|
||||
assetsSyncToken: process.env.ASSETS_SYNC_TOKEN || null,
|
||||
},
|
||||
|
||||
// Local cache of the Assets Store schema. Backed by a JSON file on disk;
|
||||
// refreshed periodically via the personal PAT above.
|
||||
storesCache: {
|
||||
enabled: boolFromEnv('STORES_CACHE_ENABLED', true),
|
||||
// Where the on-disk snapshot lives. Default is data/stores.json under
|
||||
// the process cwd; gitignored.
|
||||
path: process.env.STORES_CACHE_PATH?.trim()
|
||||
|| path.resolve(process.cwd(), 'data', 'stores.json'),
|
||||
refreshIntervalHours: intFromEnv('STORES_CACHE_REFRESH_HOURS', 24),
|
||||
staleAfterHours: intFromEnv('STORES_CACHE_STALE_AFTER_HOURS', 48),
|
||||
pageSize: intFromEnv('STORES_CACHE_PAGE_SIZE', 500),
|
||||
maxPages: intFromEnv('STORES_CACHE_MAX_PAGES', 200),
|
||||
},
|
||||
|
||||
xai: {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,9 @@
|
|||
import express from 'express';
|
||||
import axios from 'axios';
|
||||
import axiosRetry from 'axios-retry';
|
||||
import { logger, webexLogger } from '../utilities/logger.js';
|
||||
import * as jiraService from '../services/jiraService.js';
|
||||
import grokService from '../services/grokService.js';
|
||||
import config from '../config/index.js';
|
||||
|
||||
// Configure retry for transient failures
|
||||
axiosRetry(axios, {
|
||||
retries: 3,
|
||||
retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000,
|
||||
retryCondition: (error) => {
|
||||
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
|
||||
error.response?.status === 429 ||
|
||||
error.response?.status >= 500;
|
||||
}
|
||||
});
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ========================
|
||||
|
|
@ -325,6 +312,40 @@ router.post('/wxccai/ticket/:key/close', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ========================
|
||||
// Stores cache admin
|
||||
// GET /api/wxccai/admin/storesCache/status — snapshot of cache state
|
||||
// POST /api/wxccai/admin/storesCache/refresh — force an immediate resync
|
||||
//
|
||||
// These are admin endpoints (not user-facing). The status endpoint is safe to
|
||||
// hit from a health check; the refresh endpoint triggers an AQL walk of the
|
||||
// entire Store object type via the personal PAT and takes a few seconds on a
|
||||
// warm connection. Refresh is a no-op if credentials aren't configured or a
|
||||
// sync is already in progress.
|
||||
// ========================
|
||||
router.get('/wxccai/admin/storesCache/status', (req, res) => {
|
||||
try {
|
||||
res.json(jiraService.storesCache.status());
|
||||
} catch (err) {
|
||||
logger.error('storesCache status failed', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/wxccai/admin/storesCache/refresh', async (req, res) => {
|
||||
try {
|
||||
const result = await jiraService.storesCache.refresh({ force: true });
|
||||
res.json({ success: true, ...result, status: jiraService.storesCache.status() });
|
||||
} catch (err) {
|
||||
logger.error('storesCache refresh failed', { error: err.message });
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: err.message,
|
||||
status: jiraService.storesCache.status(),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ========================
|
||||
// Assets diagnostic (non-production only)
|
||||
// GET /api/wxccai/debug/assetsProbe?storeNumber=305
|
||||
|
|
|
|||
732
src/services/jira/assets.js
Normal file
732
src/services/jira/assets.js
Normal file
|
|
@ -0,0 +1,732 @@
|
|||
// Jira Assets (formerly Insight / CMDB) integration.
|
||||
//
|
||||
// Store objects are "service objects" and therefore live behind the
|
||||
// workspace-scoped API:
|
||||
// POST https://api.atlassian.com/jsm/assets/workspace/{workspaceId}/v1/object/aql
|
||||
//
|
||||
// This module contains:
|
||||
// - Low-level helpers (workspace discovery, AQL POST, Assets GET) that
|
||||
// never throw on non-2xx so callers can inspect what happened.
|
||||
// - resolveStoreAssetReference: production path — store number → object ref.
|
||||
// - probeAssetsForStore: dev-only diagnostic that runs many AQL variants
|
||||
// plus schema/object-type introspection and returns a plain-English
|
||||
// diagnosis of what's misconfigured.
|
||||
import axios from 'axios';
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
import { jiraClient } from './client.js';
|
||||
import * as storesCache from './storesCache.js';
|
||||
import { assetsAql as syncAssetsAql, isAssetsSyncConfigured } from './assetsSyncClient.js';
|
||||
|
||||
/**
|
||||
* Resolve the Assets workspace id. Prefers the explicit env var; otherwise
|
||||
* discovers via /rest/servicedeskapi/assets/workspace.
|
||||
*/
|
||||
async function getAssetsWorkspaceId() {
|
||||
if (config.jira.assetsWorkspaceId) {
|
||||
return config.jira.assetsWorkspaceId;
|
||||
}
|
||||
|
||||
const list = await listAssetsWorkspacesRaw();
|
||||
const first = list.workspaces[0];
|
||||
if (first?.workspaceId) {
|
||||
logger.info(`Discovered Assets workspaceId via /rest/servicedeskapi/assets/workspace: ${first.workspaceId}`);
|
||||
if (list.workspaces.length > 1) {
|
||||
logger.warn(`Multiple Assets workspaces are visible to this account (${list.workspaces.length}); using the first. Set JIRA_ASSETS_WORKSPACE_ID explicitly to disambiguate.`, {
|
||||
workspaces: list.workspaces.map(w => w.workspaceId)
|
||||
});
|
||||
}
|
||||
return first.workspaceId;
|
||||
}
|
||||
|
||||
throw new Error('JIRA_ASSETS_WORKSPACE_ID is required for Assets object lookup (or ensure /rest/servicedeskapi/assets/workspace is accessible).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the full list of Assets workspaces the current account can see, plus
|
||||
* the raw payload for diagnostics. Never throws.
|
||||
*/
|
||||
async function listAssetsWorkspacesRaw() {
|
||||
try {
|
||||
const resp = await jiraClient.get('/rest/servicedeskapi/assets/workspace');
|
||||
const data = resp.data;
|
||||
|
||||
let entries = [];
|
||||
if (Array.isArray(data)) entries = data;
|
||||
else if (Array.isArray(data?.values)) entries = data.values;
|
||||
else if (Array.isArray(data?.workspaces)) entries = data.workspaces;
|
||||
else if (data && typeof data === 'object') entries = [data];
|
||||
|
||||
const workspaces = entries
|
||||
.map(e => ({ workspaceId: e.workspaceId || e.id || e.key || e.workspaceID || null }))
|
||||
.filter(w => w.workspaceId);
|
||||
|
||||
return { httpStatus: resp.status, workspaces, raw: data };
|
||||
} catch (e) {
|
||||
return {
|
||||
httpStatus: e.response?.status || 'network',
|
||||
workspaces: [],
|
||||
raw: e.response?.data || null,
|
||||
error: e.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out response headers that would leak scope/tenant/session identifiers
|
||||
* or noise (cookies, tracing tokens, CORS bookkeeping). We keep just the
|
||||
* Atlassian informational headers useful for debugging (rate-limit, tracing,
|
||||
* deprecation, request id).
|
||||
*/
|
||||
function pickInterestingHeaders(headers = {}) {
|
||||
const wanted = new Set([
|
||||
'content-type', 'content-length',
|
||||
'x-request-id', 'x-arequestid', 'x-arequest-id',
|
||||
'x-ratelimit-limit', 'x-ratelimit-remaining', 'x-ratelimit-reset',
|
||||
'x-atlassian-request-id', 'x-atlassian-trace-id',
|
||||
'atl-traceid', 'atl-request-id',
|
||||
'x-atlassian-server-status', 'x-atlassian-cursor',
|
||||
'x-content-type-options', 'x-frame-options',
|
||||
'deprecation', 'sunset', 'warning', 'retry-after'
|
||||
]);
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (wanted.has(k.toLowerCase())) out[k] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level AQL POST helper. Never throws on non-2xx.
|
||||
* Returns { status, statusText, data, headers, requestUrl, requestBody, workspaceId, error }.
|
||||
* Uses bare axios (not jiraClient) because the URL is api.atlassian.com, not
|
||||
* the Jira baseURL. Reuses jiraClient's Authorization header.
|
||||
*/
|
||||
async function runAssetsAql(qlQuery, { resultPerPage = 5, includeAttributes = true, extraBody = {} } = {}) {
|
||||
const workspaceId = await getAssetsWorkspaceId();
|
||||
const aqlUrl = `https://api.atlassian.com/jsm/assets/workspace/${workspaceId}/v1/object/aql`;
|
||||
|
||||
const authHeader = jiraClient.defaults.headers.Authorization
|
||||
|| jiraClient.defaults.headers.common?.Authorization;
|
||||
|
||||
const body = { qlQuery, resultPerPage, includeAttributes, ...extraBody };
|
||||
|
||||
logger.debug('Assets AQL request', { aqlUrl, qlQuery, resultPerPage });
|
||||
|
||||
try {
|
||||
const resp = await axios.post(aqlUrl, body, {
|
||||
headers: {
|
||||
'Authorization': authHeader,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
timeout: 15000,
|
||||
validateStatus: () => true
|
||||
});
|
||||
return {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
data: resp.data ?? null,
|
||||
headers: pickInterestingHeaders(resp.headers || {}),
|
||||
requestUrl: aqlUrl,
|
||||
requestBody: body,
|
||||
workspaceId
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: err.response?.status || 'network',
|
||||
statusText: err.response?.statusText || err.code || 'error',
|
||||
data: err.response?.data || null,
|
||||
headers: pickInterestingHeaders(err.response?.headers || {}),
|
||||
requestUrl: aqlUrl,
|
||||
requestBody: body,
|
||||
workspaceId,
|
||||
error: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level GET helper against api.atlassian.com Assets endpoints.
|
||||
* Same shape as runAssetsAql. Use for schema/objecttype introspection.
|
||||
*/
|
||||
async function runAssetsGet(path) {
|
||||
const workspaceId = await getAssetsWorkspaceId();
|
||||
const url = `https://api.atlassian.com/jsm/assets/workspace/${workspaceId}/v1${path}`;
|
||||
|
||||
const authHeader = jiraClient.defaults.headers.Authorization
|
||||
|| jiraClient.defaults.headers.common?.Authorization;
|
||||
|
||||
try {
|
||||
const resp = await axios.get(url, {
|
||||
headers: { 'Authorization': authHeader, 'Accept': 'application/json' },
|
||||
timeout: 15000,
|
||||
validateStatus: () => true
|
||||
});
|
||||
return {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
data: resp.data ?? null,
|
||||
headers: pickInterestingHeaders(resp.headers || {}),
|
||||
requestUrl: url,
|
||||
workspaceId
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: err.response?.status || 'network',
|
||||
statusText: err.response?.statusText || err.code || 'error',
|
||||
data: err.response?.data || null,
|
||||
headers: pickInterestingHeaders(err.response?.headers || {}),
|
||||
requestUrl: url,
|
||||
workspaceId,
|
||||
error: err.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Assets schemas visible to the current token. Critical diagnostic:
|
||||
* if this returns zero schemas, the token has no Assets access at all
|
||||
* (regardless of what workspace id is used).
|
||||
*/
|
||||
export async function listAssetsSchemas() {
|
||||
return runAssetsGet('/objectschema/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single Assets object type (id, name, attributes). If HTTP 200,
|
||||
* the token can see the type — and the attribute names in the response are
|
||||
* authoritative for AQL queries.
|
||||
*/
|
||||
export async function getAssetsObjectType(objectTypeId) {
|
||||
const [detail, attributes] = await Promise.all([
|
||||
runAssetsGet(`/objecttype/${objectTypeId}`),
|
||||
runAssetsGet(`/objecttype/${objectTypeId}/attributes`)
|
||||
]);
|
||||
return { detail, attributes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten one Assets AQL "value" (object entry) into a compact shape suitable
|
||||
* for humans debugging attribute names/values. Different Assets tenants return
|
||||
* subtly different envelopes (attributes[].objectTypeAttribute vs typeAttribute,
|
||||
* objectAttributeValues[].value vs displayValue), so we're defensive.
|
||||
*/
|
||||
function summarizeAssetsObject(obj) {
|
||||
if (!obj || typeof obj !== 'object') return null;
|
||||
|
||||
const attributes = Array.isArray(obj.attributes) ? obj.attributes.map(attr => {
|
||||
const meta = attr.objectTypeAttribute || attr.typeAttribute || {};
|
||||
const rawValues = Array.isArray(attr.objectAttributeValues) ? attr.objectAttributeValues : [];
|
||||
const values = rawValues.map(v => v.displayValue ?? v.value ?? v.searchValue ?? null).filter(v => v !== null);
|
||||
return {
|
||||
id: attr.objectTypeAttributeId || meta.id || null,
|
||||
name: meta.name || null,
|
||||
values
|
||||
};
|
||||
}) : [];
|
||||
|
||||
return {
|
||||
id: obj.id || null,
|
||||
objectKey: obj.objectKey || null,
|
||||
name: obj.label || obj.name || null,
|
||||
objectType: obj.objectType?.name || null,
|
||||
objectTypeId: obj.objectType?.id || null,
|
||||
attributes
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the JSM/Jira Cloud request-field value for a CMDB-object custom field.
|
||||
* On Jira Cloud the CMDB field expects `{ id: "<workspaceId>:<objectId>" }` —
|
||||
* NOT the old `{ objectId }` shape from Data Center / Server, which is
|
||||
* silently accepted (HTTP 204) but never actually persists to the ticket.
|
||||
*/
|
||||
function buildStoreFieldRef(objectId) {
|
||||
const workspaceId = config.jira.assetsWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
// Fall back to the legacy shape so at least *something* is sent. The
|
||||
// caller will get an empty field on the created ticket, but we log the
|
||||
// config problem loudly.
|
||||
logger.error('JIRA_ASSETS_WORKSPACE_ID not configured; store custom field write will silently no-op on Jira Cloud');
|
||||
return [{ objectId: String(objectId) }];
|
||||
}
|
||||
return [{ id: `${workspaceId}:${objectId}` }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a store number (e.g. "00305" or 305) to the Assets object reference
|
||||
* used for the Store custom field on a JSM request:
|
||||
* customfield_10261: [ { "id": "<workspaceId>:<objectId>" } ]
|
||||
*
|
||||
* Resolution order (each step logged so we can tell which path served the
|
||||
* lookup):
|
||||
* 1. Local stores cache (populated by the personal-PAT sync). Sub-ms; no
|
||||
* external call.
|
||||
* 2. Live AQL via the personal PAT — handles brand-new stores that landed
|
||||
* between scheduled syncs.
|
||||
* 3. Legacy AQL via the service account (kept as a fallback; currently
|
||||
* blocked by the Object-Type-109 permission wall tracked in Forgejo #1,
|
||||
* but will start working when that's resolved).
|
||||
*
|
||||
* Only throws once all three paths have missed.
|
||||
*/
|
||||
export async function resolveStoreAssetReference(rawStoreNumber) {
|
||||
if (!rawStoreNumber) return null;
|
||||
|
||||
const normalized = String(rawStoreNumber).padStart(5, '0');
|
||||
const objectTypeId = config.jira.assetsStoreObjectTypeId || '109';
|
||||
const attribute = config.jira.assetsStoreNumberAttribute || 'Store Number';
|
||||
const attrId = config.jira.assetsStoreNumberAttributeId;
|
||||
|
||||
// 1. Cache hit — the common case after the first sync.
|
||||
const cached = storesCache.get(normalized);
|
||||
if (cached) {
|
||||
logger.info('Resolved store via cache', {
|
||||
storeNumber: normalized,
|
||||
objectId: cached.objectId,
|
||||
cacheAge: storesCache.status().ageSeconds,
|
||||
});
|
||||
return buildStoreFieldRef(cached.objectId);
|
||||
}
|
||||
|
||||
// 2. Live lookup via the personal-PAT sync client (handles brand-new
|
||||
// stores not yet in the cache).
|
||||
if (isAssetsSyncConfigured()) {
|
||||
const ql = `objectTypeId = ${objectTypeId} AND "${attribute}" = "${normalized}"`;
|
||||
logger.info('Cache miss; trying live PAT lookup for store', { storeNumber: normalized, ql });
|
||||
const r = await syncAssetsAql(ql, { maxResults: 1, includeAttributes: false });
|
||||
if (r.status === 200) {
|
||||
const values = Array.isArray(r.data?.values) ? r.data.values : [];
|
||||
if (values.length > 0) {
|
||||
const objectId = extractObjectIdFromResponse(r.data);
|
||||
if (objectId) {
|
||||
logger.info('Resolved store via live PAT lookup', {
|
||||
storeNumber: normalized,
|
||||
objectId: String(objectId),
|
||||
});
|
||||
return buildStoreFieldRef(objectId);
|
||||
}
|
||||
}
|
||||
logger.warn('Live PAT lookup returned 200 but no matching object', {
|
||||
storeNumber: normalized,
|
||||
total: r.data?.total,
|
||||
});
|
||||
} else {
|
||||
logger.warn('Live PAT lookup failed', {
|
||||
storeNumber: normalized,
|
||||
status: r.status,
|
||||
error: r.error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback: legacy service-account AQL. Kept behind the cache/PAT
|
||||
// layers so it costs nothing on the happy path.
|
||||
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 (service-account fallback)', { qlQuery, storeNumber: normalized });
|
||||
|
||||
const result = await runAssetsAql(qlQuery, { resultPerPage: 1, includeAttributes: true });
|
||||
lastResult = result;
|
||||
|
||||
if (result.status !== 200) {
|
||||
logger.error('Assets AQL variant failed', {
|
||||
qlQuery,
|
||||
status: result.status,
|
||||
error: result.error,
|
||||
atlassianError: result.data
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = result.data || {};
|
||||
const values = Array.isArray(data.values) ? data.values : [];
|
||||
const total = typeof data.total === 'number' ? data.total : values.length;
|
||||
|
||||
if (total === 0 || values.length === 0) {
|
||||
logger.warn('Assets AQL returned zero results for variant', { qlQuery, total, storeNumber: normalized });
|
||||
continue;
|
||||
}
|
||||
|
||||
const objectId = extractObjectIdFromResponse(data);
|
||||
if (!objectId) {
|
||||
logger.warn('Assets AQL returned results but no extractable id', {
|
||||
qlQuery,
|
||||
storeNumber: normalized,
|
||||
valuesSample: values[0]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.info('Resolved store via service-account AQL fallback', { storeNumber: normalized, objectId: String(objectId) });
|
||||
return buildStoreFieldRef(objectId);
|
||||
}
|
||||
|
||||
// All three paths missed — surface a message that tells the caller which
|
||||
// routes were tried and what the cache looked like.
|
||||
const cacheStatus = storesCache.status();
|
||||
const total = lastResult?.data?.total ?? 'unknown';
|
||||
const cacheHint = cacheStatus.assetsSyncConfigured
|
||||
? `cache has ${cacheStatus.storeCount} entries, last synced ${cacheStatus.lastSyncAt || 'never'}`
|
||||
: 'cache is not populated (ASSETS_SYNC_EMAIL/ASSETS_SYNC_TOKEN not configured)';
|
||||
throw new Error(
|
||||
`Failed to resolve Store Number ${normalized} via Assets (objectTypeId=${objectTypeId}). ` +
|
||||
`Tried: local cache, live PAT lookup, service-account AQL. ${cacheHint}. Last service-account total=${total}. ` +
|
||||
`Try POST /api/wxccai/admin/storesCache/refresh to force a resync.`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostic helper: run several AQL variants for a given store number and
|
||||
* return each result side-by-side, plus workspace/schema/object-type
|
||||
* introspection and a plain-English diagnosis. Intended for a dev-only debug
|
||||
* endpoint.
|
||||
*/
|
||||
export async function probeAssetsForStore(rawStoreNumber, { extraVariants = [] } = {}) {
|
||||
const raw = String(rawStoreNumber ?? '').trim();
|
||||
const padded = raw ? raw.padStart(5, '0') : '';
|
||||
const unpadded = raw.replace(/^0+/, '') || raw;
|
||||
|
||||
const objectTypeId = config.jira.assetsStoreObjectTypeId || '109';
|
||||
const schemaId = config.jira.assetsStoreSchemaId;
|
||||
const attribute = config.jira.assetsStoreNumberAttribute || 'Store Number';
|
||||
const attrId = config.jira.assetsStoreNumberAttributeId;
|
||||
|
||||
// A. Workspace discovery — did we get one at all? What are ALL visible workspaces?
|
||||
const workspacesList = await listAssetsWorkspacesRaw();
|
||||
let workspaceIdResolved = null;
|
||||
let workspaceIdError = null;
|
||||
try {
|
||||
workspaceIdResolved = await getAssetsWorkspaceId();
|
||||
} catch (e) {
|
||||
workspaceIdError = e.message;
|
||||
}
|
||||
|
||||
// B. Schemas the token can actually see. If empty, permissions are the issue.
|
||||
let schemasProbe = null;
|
||||
if (workspaceIdResolved) {
|
||||
const schemasResp = await listAssetsSchemas();
|
||||
let visibleSchemas = [];
|
||||
const data = schemasResp.data;
|
||||
const list = Array.isArray(data) ? data
|
||||
: Array.isArray(data?.values) ? data.values
|
||||
: Array.isArray(data?.objectschemas) ? data.objectschemas
|
||||
: Array.isArray(data?.objectSchemas) ? data.objectSchemas
|
||||
: [];
|
||||
visibleSchemas = list.map(s => ({
|
||||
id: s.id ?? null,
|
||||
name: s.name ?? null,
|
||||
objectSchemaKey: s.objectSchemaKey ?? s.key ?? null
|
||||
}));
|
||||
schemasProbe = {
|
||||
httpStatus: schemasResp.status,
|
||||
requestUrl: schemasResp.requestUrl,
|
||||
count: visibleSchemas.length,
|
||||
schemas: visibleSchemas,
|
||||
raw: schemasResp.status === 200 ? undefined : schemasResp.data,
|
||||
headers: schemasResp.headers
|
||||
};
|
||||
}
|
||||
|
||||
// C. Object type detail — is object type 109 visible? What are its attributes actually called?
|
||||
let objectTypeProbe = null;
|
||||
if (workspaceIdResolved) {
|
||||
const { detail, attributes } = await getAssetsObjectType(objectTypeId);
|
||||
const attrList = Array.isArray(attributes.data) ? attributes.data
|
||||
: Array.isArray(attributes.data?.values) ? attributes.data.values
|
||||
: [];
|
||||
objectTypeProbe = {
|
||||
detail: {
|
||||
httpStatus: detail.status,
|
||||
requestUrl: detail.requestUrl,
|
||||
name: detail.data?.name ?? null,
|
||||
objectSchemaId: detail.data?.objectSchemaId ?? null,
|
||||
raw: detail.status === 200 ? { id: detail.data?.id, name: detail.data?.name, objectSchemaId: detail.data?.objectSchemaId, description: detail.data?.description } : detail.data,
|
||||
headers: detail.headers
|
||||
},
|
||||
attributes: {
|
||||
httpStatus: attributes.status,
|
||||
requestUrl: attributes.requestUrl,
|
||||
count: attrList.length,
|
||||
names: attrList.map(a => ({
|
||||
id: a.id ?? null,
|
||||
name: a.name ?? null,
|
||||
type: a.type ?? a.defaultType?.name ?? null,
|
||||
system: a.system ?? null
|
||||
})),
|
||||
raw: attributes.status === 200 ? undefined : attributes.data,
|
||||
headers: attributes.headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// -------- AQL variants --------
|
||||
const variants = [];
|
||||
|
||||
if (schemaId) {
|
||||
variants.push({ label: 'schema_probe', qlQuery: `objectSchemaId = ${schemaId}`, resultPerPage: 5 });
|
||||
}
|
||||
variants.push({ label: 'object_type_probe', qlQuery: `objectTypeId = ${objectTypeId}`, resultPerPage: 5 });
|
||||
variants.push({ label: 'object_type_by_name', qlQuery: `objectType = "Store"`, resultPerPage: 5 });
|
||||
|
||||
if (raw) {
|
||||
variants.push({ label: 'attr_name_padded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" = "${padded}"`, resultPerPage: 3 });
|
||||
variants.push({ label: 'attr_name_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" = "${unpadded}"`, resultPerPage: 3 });
|
||||
variants.push({ label: 'attr_name_like_padded', qlQuery: `objectTypeId = ${objectTypeId} AND "${attribute}" LIKE "${padded}"`, resultPerPage: 3 });
|
||||
|
||||
variants.push({ label: 'name_padded', qlQuery: `objectTypeId = ${objectTypeId} AND Name = "${padded}"`, resultPerPage: 3 });
|
||||
variants.push({ label: 'name_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND Name = "${unpadded}"`, resultPerPage: 3 });
|
||||
variants.push({ label: 'name_like_padded', qlQuery: `objectTypeId = ${objectTypeId} AND Name LIKE "${padded}"`, resultPerPage: 3 });
|
||||
|
||||
if (schemaId) {
|
||||
variants.push({ label: 'schema_name_padded', qlQuery: `objectSchemaId = ${schemaId} AND Name = "${padded}"`, resultPerPage: 3 });
|
||||
variants.push({ label: 'schema_name_unpadded', qlQuery: `objectSchemaId = ${schemaId} AND Name = "${unpadded}"`, resultPerPage: 3 });
|
||||
}
|
||||
|
||||
if (attrId) {
|
||||
variants.push({ label: 'attr_id_padded', qlQuery: `objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${padded}"`, resultPerPage: 3 });
|
||||
variants.push({ label: 'attr_id_unpadded', qlQuery: `objectTypeId = ${objectTypeId} AND attribute[${attrId}] = "${unpadded}"`, resultPerPage: 3 });
|
||||
}
|
||||
}
|
||||
|
||||
for (const v of extraVariants) {
|
||||
variants.push({ label: v.label || 'custom', qlQuery: v.qlQuery, resultPerPage: v.resultPerPage ?? 5 });
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const v of variants) {
|
||||
const r = await runAssetsAql(v.qlQuery, { resultPerPage: v.resultPerPage, includeAttributes: true });
|
||||
const values = Array.isArray(r.data?.values) ? r.data.values : [];
|
||||
results.push({
|
||||
variant: v.label,
|
||||
qlQuery: v.qlQuery,
|
||||
httpStatus: r.status,
|
||||
statusText: r.statusText,
|
||||
total: r.data?.total ?? values.length,
|
||||
objects: values.map(summarizeAssetsObject),
|
||||
atlassianError: r.status === 200 ? undefined : r.data,
|
||||
headers: r.headers,
|
||||
// Truncated raw body so we can see everything Atlassian sent back
|
||||
// (some tenants surface hints in "hasMoreResults", "objectTypeAttributes", etc.)
|
||||
rawBody: r.data && typeof r.data === 'object'
|
||||
? JSON.parse(JSON.stringify(r.data))
|
||||
: r.data
|
||||
});
|
||||
}
|
||||
|
||||
const diagnosis = buildAssetsProbeDiagnosis({
|
||||
workspaceIdResolved,
|
||||
workspaceIdError,
|
||||
workspacesList,
|
||||
schemasProbe,
|
||||
objectTypeProbe,
|
||||
variantResults: results,
|
||||
configuredAttribute: attribute,
|
||||
configuredAttributeId: attrId,
|
||||
configuredSchemaId: schemaId,
|
||||
configuredObjectTypeId: objectTypeId,
|
||||
jiraEmail: config.jira.email
|
||||
});
|
||||
|
||||
return {
|
||||
input: { raw, padded, unpadded },
|
||||
config: {
|
||||
workspaceId: workspaceIdResolved || `error: ${workspaceIdError}`,
|
||||
objectTypeId,
|
||||
schemaId: schemaId || null,
|
||||
attribute,
|
||||
attrId: attrId || null,
|
||||
storeCustomFieldId: config.jira.storeCustomFieldId || 'customfield_10261',
|
||||
authType: config.jira.authType,
|
||||
jiraEmail: config.jira.email ? maskEmail(config.jira.email) : null
|
||||
},
|
||||
workspace: {
|
||||
resolvedId: workspaceIdResolved,
|
||||
error: workspaceIdError,
|
||||
allVisible: workspacesList.workspaces,
|
||||
httpStatus: workspacesList.httpStatus
|
||||
},
|
||||
schemas: schemasProbe,
|
||||
objectType: objectTypeProbe,
|
||||
variants: results,
|
||||
diagnosis
|
||||
};
|
||||
}
|
||||
|
||||
function maskEmail(email) {
|
||||
if (!email || !email.includes('@')) return email || null;
|
||||
const [local, domain] = email.split('@');
|
||||
const shown = local.length <= 3 ? local[0] : `${local.slice(0, 3)}…`;
|
||||
return `${shown}@${domain}`;
|
||||
}
|
||||
|
||||
function buildAssetsProbeDiagnosis({
|
||||
workspaceIdResolved,
|
||||
workspaceIdError,
|
||||
workspacesList,
|
||||
schemasProbe,
|
||||
objectTypeProbe,
|
||||
variantResults,
|
||||
configuredAttribute,
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
configuredAttributeId,
|
||||
configuredSchemaId,
|
||||
configuredObjectTypeId,
|
||||
jiraEmail
|
||||
}) {
|
||||
const notes = [];
|
||||
const suggestions = [];
|
||||
let likelyCause = 'unknown';
|
||||
|
||||
const visibleWorkspaces = workspacesList?.workspaces || [];
|
||||
const email = jiraEmail || '(JIRA_EMAIL)';
|
||||
|
||||
if (!workspaceIdResolved) {
|
||||
likelyCause = 'workspace_not_discovered';
|
||||
notes.push(`Could not discover Assets workspace id (${workspaceIdError}).`);
|
||||
suggestions.push('Set JIRA_ASSETS_WORKSPACE_ID explicitly, or ensure /rest/servicedeskapi/assets/workspace is reachable.');
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
|
||||
if (visibleWorkspaces.length > 1) {
|
||||
notes.push(`Account can see ${visibleWorkspaces.length} Assets workspaces: ${visibleWorkspaces.map(w => w.workspaceId).join(', ')}. Using ${workspaceIdResolved}.`);
|
||||
suggestions.push('If the Store schema lives in a different workspace, set JIRA_ASSETS_WORKSPACE_ID explicitly.');
|
||||
}
|
||||
|
||||
const schemaHttp = schemasProbe?.httpStatus;
|
||||
const schemaCount = schemasProbe?.count ?? 0;
|
||||
|
||||
if (schemaHttp && schemaHttp !== 200) {
|
||||
likelyCause = 'schema_list_error';
|
||||
notes.push(`GET /objectschema/list returned HTTP ${schemaHttp}. The token cannot list schemas.`);
|
||||
if (schemaHttp === 401 || schemaHttp === 403) {
|
||||
suggestions.push(`Add ${email} to an Object Schema role on the target schema in Jira → Assets → Object schemas → Configure → Roles. In Assets, API-token scopes (read:cmdb-*:jira) are NOT sufficient on their own; the user still needs schema-level role membership.`);
|
||||
}
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
|
||||
if (schemaCount === 0) {
|
||||
likelyCause = 'no_schema_visibility';
|
||||
notes.push('GET /objectschema/list returned HTTP 200 with 0 schemas — this account has no visibility to any Assets schema, so every AQL against it returns total=0.');
|
||||
suggestions.push(`Ask a Jira Assets admin to add ${email} to a role on the Store schema in Jira → Assets → Object schemas → Configure → Roles. "Object Schema Viewer" or "Object Schema User" is enough to look up store objects; "Developer" is needed to create/update objects.`);
|
||||
suggestions.push('The four read:cmdb-* / write:cmdb-* scopes on the token are necessary but not sufficient — Assets enforces a separate per-schema role check on top of the OAuth scopes.');
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
|
||||
const visibleSchemaIds = (schemasProbe?.schemas || []).map(s => String(s.id));
|
||||
const visibleSchemaSummary = (schemasProbe?.schemas || []).map(s => `${s.id}:${s.name}`).join(', ');
|
||||
notes.push(`Account can see ${schemaCount} schema(s): ${visibleSchemaSummary}.`);
|
||||
|
||||
if (configuredSchemaId && !visibleSchemaIds.includes(String(configuredSchemaId))) {
|
||||
likelyCause = 'schema_not_visible';
|
||||
notes.push(`Configured JIRA_ASSETS_STORE_SCHEMA_ID=${configuredSchemaId} is NOT in the list of schemas this account can see. AQL against schema ${configuredSchemaId} will always return total=0.`);
|
||||
suggestions.push(`Ask a Jira Assets admin to add ${email} to a role on the Store schema (id ${configuredSchemaId}) in Jira → Assets → Object schemas → Configure → Roles. "Object Schema Viewer" is enough for read; "Developer" for writes.`);
|
||||
suggestions.push('Reminder: Jira Assets enforces per-schema role membership on top of OAuth scopes. Granting the token the read:cmdb-* / write:cmdb-* scopes is necessary but NOT sufficient — the underlying user must also be in a role on the schema.');
|
||||
if (visibleSchemaIds.length === 1) {
|
||||
suggestions.push(`Right now the account is only in a role on schema ${visibleSchemaIds[0]} (${visibleSchemaSummary}). Same admin action needs to happen for the Store schema.`);
|
||||
}
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
|
||||
const otDetailStatus = objectTypeProbe?.detail?.httpStatus;
|
||||
const otAttrStatus = objectTypeProbe?.attributes?.httpStatus;
|
||||
|
||||
if (otDetailStatus && otDetailStatus !== 200) {
|
||||
if (otDetailStatus === 403) {
|
||||
likelyCause = 'object_type_forbidden';
|
||||
notes.push(`GET /objecttype/${configuredObjectTypeId} returned HTTP 403. The account can list schemas but cannot see this object type — almost always because it is missing an Object Schema role on the Store schema.`);
|
||||
suggestions.push(`Add ${email} to an Object Schema role on the Store schema in Jira → Assets → Object schemas → Configure → Roles.`);
|
||||
} else {
|
||||
likelyCause = 'object_type_not_visible';
|
||||
notes.push(`GET /objecttype/${configuredObjectTypeId} returned HTTP ${otDetailStatus}. The configured objectTypeId is either wrong or not visible to this account.`);
|
||||
suggestions.push(`Verify JIRA_ASSETS_STORE_OBJECT_TYPE_ID matches the actual Store type id in Jira Assets.`);
|
||||
}
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
|
||||
if (otDetailStatus === 200) {
|
||||
notes.push(`Object type is visible: ${objectTypeProbe.detail.name} (schema ${objectTypeProbe.detail.objectSchemaId}).`);
|
||||
}
|
||||
|
||||
if (otAttrStatus === 200 && objectTypeProbe.attributes.count > 0) {
|
||||
const attrNames = objectTypeProbe.attributes.names.map(a => a.name).filter(Boolean);
|
||||
const attrMatch = attrNames.find(n => n.toLowerCase() === (configuredAttribute || '').toLowerCase());
|
||||
if (!attrMatch) {
|
||||
likelyCause = 'attribute_name_mismatch';
|
||||
notes.push(`The configured attribute "${configuredAttribute}" is NOT among the object type's attributes. Actual attribute names: ${attrNames.join(', ')}.`);
|
||||
const guess = attrNames.find(n => /store|number|store\s*id/i.test(n));
|
||||
if (guess) suggestions.push(`Set JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE="${guess}" (or use the id form via JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE_ID).`);
|
||||
else suggestions.push('Pick the correct attribute from the list above and set JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE (or ...ATTRIBUTE_ID) accordingly.');
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
notes.push(`Attribute "${configuredAttribute}" exists on the object type.`);
|
||||
}
|
||||
|
||||
const anyHits = variantResults.some(v => v.total > 0);
|
||||
if (!anyHits) {
|
||||
likelyCause = 'value_format_mismatch';
|
||||
notes.push('Schema and object type are visible but no AQL variant returned rows. Attribute values are likely stored in a form none of the variants matched.');
|
||||
suggestions.push('Re-run the probe without a storeNumber to sample real Store objects: curl "http://localhost:1866/api/wxccai/debug/assetsProbe" — the object_type_probe row will show up to 5 real Store objects with their actual attribute values, so you can see how Store Number is stored (leading zeros, prefix, etc.).');
|
||||
} else {
|
||||
const winners = variantResults.filter(v => v.total > 0).map(v => v.variant);
|
||||
likelyCause = 'success';
|
||||
notes.push(`These variants returned rows: ${winners.join(', ')}. Lock resolveStoreAssetReference to the first one.`);
|
||||
}
|
||||
|
||||
return { likelyCause, notes, suggestions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an Assets object id from a variety of AQL response shapes.
|
||||
* Tries top-level, then common list keys, then a bounded deep search.
|
||||
*/
|
||||
function extractObjectIdFromResponse(respData) {
|
||||
if (!respData || typeof respData !== 'object') return null;
|
||||
|
||||
if (respData.id) return respData.id;
|
||||
if (respData.objectId) return respData.objectId;
|
||||
|
||||
const listKeys = ['values', 'objectEntries', 'objects', 'objectList', 'results', 'items'];
|
||||
for (const key of listKeys) {
|
||||
const list = respData[key];
|
||||
if (Array.isArray(list)) {
|
||||
for (const item of list) {
|
||||
if (item && typeof item === 'object') {
|
||||
if (item.id) return item.id;
|
||||
if (item.objectId) return item.objectId;
|
||||
if (item.object && item.object.id) return item.object.id;
|
||||
if (item.attributes && item.attributes.id) return item.attributes.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deepFind(obj, depth = 0) {
|
||||
if (depth > 6 || obj == null || typeof obj !== 'object') return null;
|
||||
if (obj.id && (typeof obj.id === 'string' || typeof obj.id === 'number')) return obj.id;
|
||||
if (obj.objectId && (typeof obj.objectId === 'string' || typeof obj.objectId === 'number')) return obj.objectId;
|
||||
if (Array.isArray(obj)) {
|
||||
for (const el of obj) {
|
||||
const found = deepFind(el, depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
} else {
|
||||
for (const k of Object.keys(obj)) {
|
||||
const found = deepFind(obj[k], depth + 1);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return deepFind(respData);
|
||||
}
|
||||
150
src/services/jira/assetsSyncClient.js
Normal file
150
src/services/jira/assetsSyncClient.js
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Dedicated Jira Assets client that authenticates as a *personal* Atlassian
|
||||
// account (email + PAT), not the shared service account used everywhere else.
|
||||
//
|
||||
// Why this exists:
|
||||
// The service account has schema-level read on the Stores schema but is
|
||||
// silently filtered out for objects in Object Type 109 (Store Address /
|
||||
// Hierarchy). Every direct API path (raw Assets, site gateway, servicedeskapi
|
||||
// CMDB proxy) is either 403 or returns total=0. A personal account with the
|
||||
// right Assets role sees the objects fine, so this client is used *only* by
|
||||
// the store cache sync — never for anything that mutates state on Atlassian's
|
||||
// side.
|
||||
//
|
||||
// Credentials come from config.jira.assetsSyncEmail / assetsSyncToken. Those
|
||||
// are populated from env vars (which are typically exported from macOS
|
||||
// Keychain via bin/load-assets-sync-secret.sh). If either is missing this
|
||||
// module is inert and the caller falls back to the service account path.
|
||||
import axios from 'axios';
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
|
||||
const ASSETS_HOST = 'https://api.atlassian.com';
|
||||
|
||||
let cachedAuthHeader = null;
|
||||
let warnedMissing = false;
|
||||
|
||||
function buildAuthHeader() {
|
||||
const email = config.jira.assetsSyncEmail;
|
||||
const token = config.jira.assetsSyncToken;
|
||||
if (!email || !token) return null;
|
||||
const encoded = Buffer.from(`${email}:${token}`).toString('base64');
|
||||
return `Basic ${encoded}`;
|
||||
}
|
||||
|
||||
function getAuthHeader() {
|
||||
if (cachedAuthHeader) return cachedAuthHeader;
|
||||
cachedAuthHeader = buildAuthHeader();
|
||||
if (!cachedAuthHeader && !warnedMissing) {
|
||||
logger.warn('Assets sync credentials not configured (ASSETS_SYNC_EMAIL / ASSETS_SYNC_TOKEN). Stores cache sync is disabled; resolveStoreAssetReference will fall back to the service-account AQL path.');
|
||||
warnedMissing = true;
|
||||
}
|
||||
return cachedAuthHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* True iff both an email and a PAT are configured for the personal-account
|
||||
* Assets sync path.
|
||||
*/
|
||||
export function isAssetsSyncConfigured() {
|
||||
return !!getAuthHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort masked identity for logging / status endpoints. Never returns
|
||||
* the token.
|
||||
*/
|
||||
export function describeSyncIdentity() {
|
||||
const email = config.jira.assetsSyncEmail || null;
|
||||
return {
|
||||
configured: isAssetsSyncConfigured(),
|
||||
email: email
|
||||
? (email.includes('@')
|
||||
? `${email.slice(0, 3)}…@${email.split('@')[1]}`
|
||||
: `${email.slice(0, 3)}…`)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level AQL POST via the personal PAT. Never throws on non-2xx — always
|
||||
* returns `{ status, statusText, data, headers, requestUrl, requestBody, error }`.
|
||||
*
|
||||
* `qlQuery` raw AQL string, e.g. `objectTypeId = 109`
|
||||
* `opts` { startAt, maxResults, includeAttributes, extraBody, timeoutMs }
|
||||
*
|
||||
* Endpoint reference: Atlassian Assets REST API v1 `POST /object/aql`
|
||||
* - Query params: `startAt` (default 0), `maxResults` (default 25, cap
|
||||
* varies by tenant but 500 is safe), `includeAttributes` (default true)
|
||||
* - Body: `{ "qlQuery": "..." }`
|
||||
* - Response: `{ startAt, maxResults, total, isLast, values: [...] }`
|
||||
*
|
||||
* The old `page` / `resultPerPage` params are for a different endpoint and
|
||||
* are silently ignored here — always use startAt/maxResults on this one.
|
||||
*/
|
||||
export async function assetsAql(qlQuery, opts = {}) {
|
||||
const auth = getAuthHeader();
|
||||
if (!auth) {
|
||||
return {
|
||||
status: 'not_configured',
|
||||
statusText: 'ASSETS_SYNC_EMAIL / ASSETS_SYNC_TOKEN not set',
|
||||
data: null,
|
||||
error: 'assets sync credentials not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const workspaceId = config.jira.assetsWorkspaceId;
|
||||
if (!workspaceId) {
|
||||
return {
|
||||
status: 'not_configured',
|
||||
statusText: 'JIRA_ASSETS_WORKSPACE_ID not set',
|
||||
data: null,
|
||||
error: 'assets workspace id not configured',
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
startAt = 0,
|
||||
maxResults = 500,
|
||||
includeAttributes = true,
|
||||
extraBody = {},
|
||||
timeoutMs = 30000,
|
||||
} = opts;
|
||||
|
||||
const qp = new URLSearchParams({
|
||||
startAt: String(startAt),
|
||||
maxResults: String(maxResults),
|
||||
includeAttributes: String(includeAttributes),
|
||||
});
|
||||
const url = `${ASSETS_HOST}/jsm/assets/workspace/${workspaceId}/v1/object/aql?${qp.toString()}`;
|
||||
const body = { qlQuery, ...extraBody };
|
||||
|
||||
try {
|
||||
const resp = await axios.post(url, body, {
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
timeout: timeoutMs,
|
||||
validateStatus: () => true,
|
||||
});
|
||||
return {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
data: resp.data ?? null,
|
||||
headers: resp.headers,
|
||||
requestUrl: url,
|
||||
requestBody: body,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
status: err.response?.status || 'network',
|
||||
statusText: err.response?.statusText || err.code || 'error',
|
||||
data: err.response?.data || null,
|
||||
headers: err.response?.headers || {},
|
||||
requestUrl: url,
|
||||
requestBody: body,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
154
src/services/jira/attachments.js
Normal file
154
src/services/jira/attachments.js
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// Jira attachments + Webex CC transcript conversion.
|
||||
// Downloads happen via downloadClient (own retry policy); uploads use jiraClient.
|
||||
import FormData from 'form-data';
|
||||
import logger from '../../utilities/logger.js';
|
||||
import { jiraClient, downloadClient } from './client.js';
|
||||
|
||||
/**
|
||||
* Shared helper: POST a Buffer as multipart attachment to the core Jira
|
||||
* attachments endpoint.
|
||||
* - Uses jiraClient (correct base + auth).
|
||||
* - Spreads form.getHeaders() so boundary is set.
|
||||
* - Cleans any charset from Content-Type (prevents 415).
|
||||
* - Retry loop only around the API call (download is caller's job).
|
||||
* - Fail-fast on 401/403 (scope/perms).
|
||||
*/
|
||||
async function attachBufferToJira(jiraKey, fileBuffer, fileName) {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append('file', fileBuffer, fileName);
|
||||
|
||||
const formHeaders = form.getHeaders();
|
||||
if (formHeaders['content-type']) {
|
||||
formHeaders['content-type'] = formHeaders['content-type'].replace(/;\s*charset=[^;]*/i, '');
|
||||
}
|
||||
|
||||
const uploadPath = `/rest/api/3/issue/${jiraKey}/attachments`;
|
||||
|
||||
await jiraClient.post(uploadPath, form, {
|
||||
headers: {
|
||||
'X-Atlassian-Token': 'no-check',
|
||||
...formHeaders
|
||||
},
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
logger.info('File attached successfully', { jiraKey, fileName, attempt });
|
||||
return;
|
||||
} catch (err) {
|
||||
const status = err.response?.status;
|
||||
logger.error('Jira file attach attempt failed', {
|
||||
jiraKey,
|
||||
fileName,
|
||||
attempt,
|
||||
status,
|
||||
responseData: err.response?.data,
|
||||
responseHeaders: err.response?.headers ? Object.fromEntries(
|
||||
Object.entries(err.response.headers).filter(([k]) => !k.toLowerCase().includes('auth'))
|
||||
) : undefined
|
||||
});
|
||||
if (status === 401 || status === 403) {
|
||||
throw err;
|
||||
}
|
||||
if (attempt === 3) throw err;
|
||||
await new Promise(r => setTimeout(r, attempt * 1500));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from the given URL (S3 pre-signed) *once* and attach using
|
||||
* the core Jira attachments API. Download happens outside the retry loop
|
||||
* because the signed URL expires (~1800s).
|
||||
*/
|
||||
export async function attachFileToJira(jiraKey, fileUrl, fileName) {
|
||||
let fileBuffer;
|
||||
try {
|
||||
const dl = await downloadClient.get(fileUrl, {
|
||||
responseType: 'arraybuffer'
|
||||
});
|
||||
fileBuffer = Buffer.from(dl.data);
|
||||
} catch (dlErr) {
|
||||
logger.error('Failed to download file from S3 for attachment (URL likely expired on replay)', {
|
||||
jiraKey,
|
||||
fileName,
|
||||
url: fileUrl,
|
||||
status: dlErr.response?.status,
|
||||
message: dlErr.message
|
||||
});
|
||||
throw dlErr;
|
||||
}
|
||||
|
||||
await attachBufferToJira(jiraKey, fileBuffer, fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Webex-style JSON transcript to human-readable text.
|
||||
* Returns null if the shape isn't recognized.
|
||||
*/
|
||||
function formatTranscriptToHumanReadable(data) {
|
||||
if (!data || !Array.isArray(data.responseContents)) return null;
|
||||
const lines = [];
|
||||
lines.push(`Transcript`);
|
||||
if (data.interactionId) lines.push(`Interaction ID: ${data.interactionId}`);
|
||||
if (data.languageCode) lines.push(`Language: ${data.languageCode}`);
|
||||
lines.push('');
|
||||
for (const entry of data.responseContents) {
|
||||
const res = entry.recognitionResult;
|
||||
if (!res || !res.alternatives || !res.alternatives[0]) continue;
|
||||
const role = (res.role || 'UNKNOWN').toUpperCase();
|
||||
const alt = res.alternatives[0];
|
||||
const transcript = (alt.transcript || '').trim();
|
||||
if (!transcript) continue;
|
||||
let ts = '';
|
||||
const words = alt.words || [];
|
||||
if (words.length > 0) {
|
||||
const start = words[0].start_time || {};
|
||||
const totalSec = (start.seconds || 0) + Math.floor((start.nanos || 0) / 1e9);
|
||||
const min = Math.floor(totalSec / 60);
|
||||
const sec = Math.floor(totalSec % 60);
|
||||
ts = `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}] `;
|
||||
}
|
||||
lines.push(`${ts}${role}: ${transcript}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function fetchAndConvertTranscript(url) {
|
||||
try {
|
||||
const resp = await downloadClient.get(url, { timeout: 10000 });
|
||||
return formatTranscriptToHumanReadable(resp.data);
|
||||
} catch (e) {
|
||||
logger.warn(`Failed to fetch/convert transcript: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the JSON transcript, convert to human-readable text, and attach as
|
||||
* `<base>-readable.txt`. Failures are swallowed so they don't mark the
|
||||
* original JSON attach as failed.
|
||||
*/
|
||||
export async function attachReadableTranscript(jiraKey, transcriptUrl, originalFileName = null) {
|
||||
const readable = await fetchAndConvertTranscript(transcriptUrl);
|
||||
if (!readable) {
|
||||
logger.warn('Readable transcript conversion yielded no content (check transcript JSON shape or URL)', { jiraKey });
|
||||
return false;
|
||||
}
|
||||
const base = (originalFileName || `transcript-${jiraKey}`).replace(/\.json$/i, '');
|
||||
const fileName = `${base}-readable.txt`;
|
||||
|
||||
try {
|
||||
await attachBufferToJira(jiraKey, Buffer.from(readable, 'utf8'), fileName);
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error('Readable transcript attach failed (non-fatal)', {
|
||||
jiraKey,
|
||||
fileName,
|
||||
error: err.response?.data?.message || err.message,
|
||||
status: err.response?.status
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
84
src/services/jira/client.js
Normal file
84
src/services/jira/client.js
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
// Foundational Jira module: shared axios instances + tiny ADF helper.
|
||||
// Nothing else in services/jira/* should import axios or axios-retry directly.
|
||||
import axios from 'axios';
|
||||
import axiosRetry from 'axios-retry';
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
|
||||
// Reusable Jira client with flexible auth. No default Content-Type on the
|
||||
// instance because callers need both JSON bodies AND multipart/form-data
|
||||
// (attachments). Content-Type is set explicitly per-request when needed.
|
||||
const createJiraClient = () => {
|
||||
const headers = {};
|
||||
const effectiveBase = config.jira.baseUrl || '(not configured)';
|
||||
logger.debug(`[jira] baseUrl=${effectiveBase} authType=${config.jira.authType}`);
|
||||
|
||||
if (config.jira.authType === 'bearer') {
|
||||
headers.Authorization = `Bearer ${config.jira.apiToken}`;
|
||||
logger.info('Using Jira Bearer Token authentication');
|
||||
} else {
|
||||
const authStr = `${config.jira.email}:${config.jira.apiToken}`;
|
||||
headers.Authorization = `Basic ${Buffer.from(authStr).toString('base64')}`;
|
||||
logger.info('Using Jira Basic Auth');
|
||||
}
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: config.jira.baseUrl,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Retry policy scoped to this client only. Never mutates the default
|
||||
// axios instance — anything else that needs retries uses its own client.
|
||||
axiosRetry(client, {
|
||||
retries: 3,
|
||||
retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000,
|
||||
retryCondition: (error) => {
|
||||
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
|
||||
error.response?.status === 429 ||
|
||||
error.response?.status >= 500;
|
||||
}
|
||||
});
|
||||
|
||||
return client;
|
||||
};
|
||||
|
||||
export const jiraClient = createJiraClient();
|
||||
|
||||
// Dedicated instance for pre-signed S3 downloads (audio + transcript files
|
||||
// from Webex CC). Separate from jiraClient because:
|
||||
// 1. No baseURL — always pass the full pre-signed URL.
|
||||
// 2. No Authorization header — the S3 URL is already signed.
|
||||
// 3. We want retries — S3 pre-signed downloads are the flakiest thing
|
||||
// in the pipeline (transient 5xx, TLS resets, TCP timeouts).
|
||||
export const downloadClient = axios.create({ timeout: 20000 });
|
||||
axiosRetry(downloadClient, {
|
||||
retries: 3,
|
||||
retryDelay: (retryCount) => Math.pow(2, retryCount) * 1000,
|
||||
retryCondition: (error) => {
|
||||
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
|
||||
error.response?.status === 429 ||
|
||||
error.response?.status >= 500;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Convert a plain string to a minimal ADF document.
|
||||
* ADF is what /rest/api/3/... expects for description/comment bodies.
|
||||
* Blank lines split paragraphs; single newlines become hardBreak nodes.
|
||||
*/
|
||||
export function plainTextToAdf(text) {
|
||||
const safe = (text ?? '').toString();
|
||||
if (!safe) {
|
||||
return { version: 1, type: 'doc', content: [] };
|
||||
}
|
||||
const paragraphs = safe.split(/\n{2,}/).map(block => {
|
||||
const parts = block.split('\n');
|
||||
const content = [];
|
||||
parts.forEach((line, idx) => {
|
||||
if (line.length) content.push({ type: 'text', text: line });
|
||||
if (idx < parts.length - 1) content.push({ type: 'hardBreak' });
|
||||
});
|
||||
return { type: 'paragraph', content };
|
||||
});
|
||||
return { version: 1, type: 'doc', content: paragraphs };
|
||||
}
|
||||
121
src/services/jira/comments.js
Normal file
121
src/services/jira/comments.js
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// Jira issue comments: fetch (normalized), add plain, and post structured
|
||||
// Webex CC summary blocks. All comment ADF construction lives here.
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
import { adfToPlainText } from '../../utilities/adfToPlainText.js';
|
||||
import { jiraClient, plainTextToAdf } from './client.js';
|
||||
|
||||
/**
|
||||
* Fetch public comments for an issue, normalized to
|
||||
* { author, body (plain text), created, createdIso }.
|
||||
* Uses the core /rest/api/3/issue/{key}/comment endpoint (servicedeskapi's
|
||||
* variant can require different auth/perms with the current cloudId + Basic
|
||||
* auth setup).
|
||||
*/
|
||||
export async function fetchPublicComments(key) {
|
||||
const url = `/rest/api/3/issue/${key}/comment`;
|
||||
try {
|
||||
const response = await jiraClient.get(url);
|
||||
const values = response.data?.values || [];
|
||||
return values.map(comment => ({
|
||||
author: comment.author?.displayName || comment.author?.name || 'Unknown',
|
||||
body: adfToPlainText(comment.body),
|
||||
created: comment.created,
|
||||
createdIso: typeof comment.created === 'string' ? comment.created : (comment.created?.iso8601 || comment.created || null)
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch public comments:', error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a comment to an issue. `text` is plain; converted to ADF here.
|
||||
* If `internal: true`, restricts visibility to `config.jira.commentVisibilityRole`
|
||||
* (same behavior as postWebexSummaryComment).
|
||||
*/
|
||||
export async function addComment(key, text, { internal = false } = {}) {
|
||||
if (!key || !/^[A-Z]+-\d+$/.test(key)) {
|
||||
throw new Error(`Invalid ticket key: "${key}"`);
|
||||
}
|
||||
if (!text || !String(text).trim()) {
|
||||
throw new Error('Comment text is required');
|
||||
}
|
||||
|
||||
const payload = { body: plainTextToAdf(String(text)) };
|
||||
if (internal) {
|
||||
payload.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' };
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await jiraClient.post(`/rest/api/3/issue/${key}/comment`, payload, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
logger.info('Comment posted', { key, commentId: data?.id, internal });
|
||||
return { key, commentId: data?.id, internal };
|
||||
} catch (err) {
|
||||
logger.error('addComment failed', { key, status: err.response?.status, details: err.response?.data });
|
||||
const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message);
|
||||
e.status = err.response?.status;
|
||||
e.details = err.response?.data;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clean ADF comment from the Webex CC AI summaries object and post it
|
||||
* to the Jira issue. Restricted visibility (via role) so only the configured
|
||||
* role sees the summary + attachment references.
|
||||
*/
|
||||
export async function postWebexSummaryComment(jiraKey, summaries, attachedFiles = []) {
|
||||
const items = [
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Initial Contact Reason: ${summaries.intialContactReason || 'N/A'}` }] }] },
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Additional Context: ${summaries.additionalContext || 'N/A'}` }] }] },
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Key Actions Taken: ${summaries.keyActionsTake || 'N/A'}` }] }] },
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Next Steps: ${summaries.nextSteps || 'N/A'}` }] }] },
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: `Resolution: ${summaries.resolution || 'N/A'}` }] }] }
|
||||
];
|
||||
|
||||
const content = [
|
||||
{
|
||||
type: "heading",
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: "text", text: "Webex Contact Center Summary" }]
|
||||
},
|
||||
{ type: "bulletList", content: items },
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: `Posted via Webex Integration — ${new Date().toISOString()}` }]
|
||||
}
|
||||
];
|
||||
|
||||
if (Array.isArray(attachedFiles) && attachedFiles.length > 0) {
|
||||
content.push({
|
||||
type: "paragraph",
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Attached files (internal): ${attachedFiles.join(', ')}`
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
const commentPayload = {
|
||||
body: {
|
||||
version: 1,
|
||||
type: "doc",
|
||||
content
|
||||
},
|
||||
visibility: {
|
||||
type: "role",
|
||||
value: config.jira.commentVisibilityRole || 'Service Desk Team'
|
||||
}
|
||||
};
|
||||
|
||||
await jiraClient.post(
|
||||
`/rest/api/3/issue/${jiraKey}/comment`,
|
||||
commentPayload,
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
logger.info('Clean summary comment posted (restricted)', { jiraKey, attachedFiles });
|
||||
}
|
||||
290
src/services/jira/issues.js
Normal file
290
src/services/jira/issues.js
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
// Jira issue lifecycle: fetch, search-by-reporter, status, update, transitions,
|
||||
// close. All project-agnostic (works for any project the token can see) and
|
||||
// uses the core /rest/api/3/issue/... endpoints.
|
||||
import logger from '../../utilities/logger.js';
|
||||
import config from '../../config/index.js';
|
||||
import { jiraClient, plainTextToAdf } from './client.js';
|
||||
import { fetchPublicComments } from './comments.js';
|
||||
|
||||
const KEY_RE = /^[A-Z]+-\d+$/;
|
||||
|
||||
export async function fetchJiraIssue(key) {
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
throw new Error(`Invalid ticket key: "${key}"`);
|
||||
}
|
||||
|
||||
const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,created,updated,priority`;
|
||||
try {
|
||||
const response = await jiraClient.get(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error('Fetch Jira issue failed:', error.response?.data || error.message);
|
||||
throw new Error(`Issue fetch failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchPlainDescription(key) {
|
||||
const payload = { expression: "issue.description.plainText", context: { issue: { key } } };
|
||||
try {
|
||||
const response = await jiraClient.post('/rest/api/3/expression/evaluate', payload, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
return response.data.value || 'No description available.';
|
||||
} catch (error) {
|
||||
logger.warn('Failed to fetch plain description:', error.message);
|
||||
return 'No description available.';
|
||||
}
|
||||
}
|
||||
|
||||
// Kept for future use (email → accountId lookup). Not currently called; the
|
||||
// reporter search uses email directly in JQL.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
async function getAccountIdFromEmail(email) {
|
||||
if (!email) {
|
||||
throw new Error('Email is required');
|
||||
}
|
||||
|
||||
const url = `/rest/api/3/user/search?query=${encodeURIComponent(email)}&maxResults=10`;
|
||||
try {
|
||||
const response = await jiraClient.get(url);
|
||||
const users = response.data || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new Error(`No users found matching "${email}"`);
|
||||
}
|
||||
|
||||
let user = users.find(u => u.emailAddress?.toLowerCase() === email.toLowerCase());
|
||||
if (!user && users.length > 0) {
|
||||
user = users[0];
|
||||
}
|
||||
|
||||
if (!user?.accountId) {
|
||||
throw new Error(`No usable accountId found for "${email}"`);
|
||||
}
|
||||
|
||||
logger.info(`Using accountId ${user.accountId} for email "${email}"`);
|
||||
return user.accountId;
|
||||
} catch (err) {
|
||||
throw new Error(`User lookup failed: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search open tickets reported by a given email (across CS/SS/SUPPORT
|
||||
* projects). Enriches each result with plain-text description + last 6
|
||||
* public comments so downstream (Grok) has full context in one round trip.
|
||||
*/
|
||||
export async function searchOpenTicketsByReporterEmail(email) {
|
||||
if (!email || typeof email !== 'string' || email.trim() === '') {
|
||||
throw new Error("Email is required");
|
||||
}
|
||||
|
||||
const jql = `project in (CS, SS, SUPPORT)
|
||||
AND reporter = "${email}"
|
||||
AND statusCategory != Done
|
||||
ORDER BY updated DESC`;
|
||||
|
||||
try {
|
||||
const response = await jiraClient.post('/rest/api/3/search/jql', {
|
||||
jql: jql,
|
||||
maxResults: 8,
|
||||
fields: ["key", "summary", "status", "updated", "description"],
|
||||
expand: "comments"
|
||||
}, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const issues = response.data.issues || [];
|
||||
|
||||
const enrichedIssues = await Promise.all(
|
||||
issues.map(async (issue) => {
|
||||
const key = issue.key;
|
||||
try {
|
||||
const [plainDesc, publicComments] = await Promise.all([
|
||||
fetchPlainDescription(key).catch(() => "No description available."),
|
||||
fetchPublicComments(key).catch(() => [])
|
||||
]);
|
||||
|
||||
issue.enrichedNotes = {
|
||||
description: plainDesc,
|
||||
publicComments: publicComments.slice(-6)
|
||||
};
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to enrich notes for ${key}:`, err.message);
|
||||
issue.enrichedNotes = { description: "Notes unavailable.", publicComments: [] };
|
||||
}
|
||||
return issue;
|
||||
})
|
||||
);
|
||||
|
||||
return enrichedIssues;
|
||||
} catch (error) {
|
||||
logger.error('Jira reporter search failed:', error.response?.data || error.message);
|
||||
throw new Error(`Failed to search tickets reported by ${email}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, purpose-built status view — no Grok, no comment enrichment.
|
||||
* Use this when a caller just wants "where is this ticket right now?".
|
||||
*/
|
||||
export async function getTicketStatus(key) {
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
throw new Error(`Invalid ticket key: "${key}"`);
|
||||
}
|
||||
|
||||
const url = `/rest/api/3/issue/${key}?fields=summary,status,assignee,reporter,priority,resolution,created,updated,labels`;
|
||||
try {
|
||||
const { data } = await jiraClient.get(url);
|
||||
const f = data.fields || {};
|
||||
return {
|
||||
key: data.key,
|
||||
summary: f.summary || null,
|
||||
status: f.status?.name || null,
|
||||
statusCategory: f.status?.statusCategory?.key || null,
|
||||
assignee: f.assignee?.displayName || f.assignee?.emailAddress || null,
|
||||
reporter: f.reporter?.displayName || f.reporter?.emailAddress || null,
|
||||
priority: f.priority?.name || null,
|
||||
resolution: f.resolution?.name || null,
|
||||
labels: f.labels || [],
|
||||
created: f.created || null,
|
||||
updated: f.updated || null
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error('getTicketStatus failed', { key, status: err.response?.status, details: err.response?.data });
|
||||
const e = new Error(`Failed to fetch status for ${key}: ${err.message}`);
|
||||
e.status = err.response?.status;
|
||||
e.details = err.response?.data;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial issue update. Accepts flat, friendly fields:
|
||||
* { summary, description, priority, labels, assigneeAccountId, additional }
|
||||
* `additional` is merged raw into the `fields` object (e.g. customfield_*).
|
||||
* `description` is a plain string; converted to ADF here.
|
||||
*/
|
||||
export async function updateTicket(key, updates = {}) {
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
throw new Error(`Invalid ticket key: "${key}"`);
|
||||
}
|
||||
|
||||
const fields = {};
|
||||
if (updates.summary !== undefined) fields.summary = String(updates.summary);
|
||||
if (updates.description !== undefined) fields.description = plainTextToAdf(updates.description);
|
||||
if (updates.priority) fields.priority = { name: String(updates.priority) };
|
||||
if (Array.isArray(updates.labels)) fields.labels = updates.labels.map(String);
|
||||
if (updates.assigneeAccountId) fields.assignee = { accountId: String(updates.assigneeAccountId) };
|
||||
if (updates.additional && typeof updates.additional === 'object') Object.assign(fields, updates.additional);
|
||||
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error('updateTicket called with no updatable fields');
|
||||
}
|
||||
|
||||
try {
|
||||
await jiraClient.put(`/rest/api/3/issue/${key}`, { fields }, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
logger.info('Ticket updated', { key, fieldKeys: Object.keys(fields) });
|
||||
return { key, updated: Object.keys(fields) };
|
||||
} catch (err) {
|
||||
logger.error('updateTicket failed', { key, status: err.response?.status, details: err.response?.data });
|
||||
const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message);
|
||||
e.status = err.response?.status;
|
||||
e.details = err.response?.data;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available workflow transitions for an issue. Useful for both the
|
||||
* client picking a transition manually and for closeTicket() below.
|
||||
*/
|
||||
export async function getTransitions(key) {
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
throw new Error(`Invalid ticket key: "${key}"`);
|
||||
}
|
||||
try {
|
||||
const { data } = await jiraClient.get(`/rest/api/3/issue/${key}/transitions`);
|
||||
return (data.transitions || []).map(t => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
to: { id: t.to?.id, name: t.to?.name, statusCategory: t.to?.statusCategory?.key },
|
||||
hasScreen: !!t.hasScreen
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error('getTransitions failed', { key, status: err.response?.status, details: err.response?.data });
|
||||
throw new Error(`Failed to fetch transitions for ${key}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a specific transition. Optionally set a resolution and/or append a
|
||||
* comment in the same call (both are fields the transition screen can accept).
|
||||
*/
|
||||
export async function transitionTicket(key, transitionId, { resolution, comment, internal = false, additionalFields } = {}) {
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
throw new Error(`Invalid ticket key: "${key}"`);
|
||||
}
|
||||
if (!transitionId) throw new Error('transitionId is required');
|
||||
|
||||
const payload = { transition: { id: String(transitionId) } };
|
||||
|
||||
const fields = { ...(additionalFields || {}) };
|
||||
if (resolution) fields.resolution = { name: String(resolution) };
|
||||
if (Object.keys(fields).length) payload.fields = fields;
|
||||
|
||||
if (comment) {
|
||||
const commentEntry = { add: { body: plainTextToAdf(String(comment)) } };
|
||||
if (internal) {
|
||||
commentEntry.add.visibility = { type: 'role', value: config.jira.commentVisibilityRole || 'Service Desk Team' };
|
||||
}
|
||||
payload.update = { comment: [commentEntry] };
|
||||
}
|
||||
|
||||
try {
|
||||
await jiraClient.post(`/rest/api/3/issue/${key}/transitions`, payload, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
logger.info('Ticket transitioned', { key, transitionId, resolution });
|
||||
return { key, transitionId, resolution: resolution || null };
|
||||
} catch (err) {
|
||||
logger.error('transitionTicket failed', {
|
||||
key, transitionId, status: err.response?.status, details: err.response?.data
|
||||
});
|
||||
const e = new Error(err.response?.data?.errorMessages?.join('; ') || err.message);
|
||||
e.status = err.response?.status;
|
||||
e.details = err.response?.data;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: find a "closing" transition and execute it.
|
||||
* Prefers explicit `transitionName` if provided, otherwise picks the first
|
||||
* transition whose target status is in category "done" (Jira's canonical
|
||||
* category for closed/resolved/completed), falling back to a name-based match.
|
||||
*/
|
||||
export async function closeTicket(key, { transitionName, resolution = 'Done', comment, internal = false } = {}) {
|
||||
const transitions = await getTransitions(key);
|
||||
if (transitions.length === 0) {
|
||||
throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`);
|
||||
}
|
||||
|
||||
let chosen = null;
|
||||
if (transitionName) {
|
||||
chosen = transitions.find(t => t.name.toLowerCase() === transitionName.toLowerCase());
|
||||
if (!chosen) {
|
||||
throw new Error(`Transition "${transitionName}" not available for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
chosen = transitions.find(t => t.to?.statusCategory === 'done')
|
||||
|| transitions.find(t => /done|closed|resolved|complete/i.test(t.name));
|
||||
if (!chosen) {
|
||||
throw new Error(`Could not find a closing transition for ${key}. Available: ${transitions.map(t => t.name).join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
return transitionTicket(key, chosen.id, { resolution, comment, internal });
|
||||
}
|
||||
149
src/services/jira/jsmRequests.js
Normal file
149
src/services/jira/jsmRequests.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
// Jira Service Management (JSM) request creation.
|
||||
// Uses /rest/servicedeskapi/request to create proper JSM customer requests
|
||||
// with request types. This is separate from the corporate-project ticket
|
||||
// flow (see issues.js). Depends on assets.js for store resolution.
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
import { jiraClient } from './client.js';
|
||||
import { resolveStoreAssetReference } from './assets.js';
|
||||
|
||||
const REQUEST_TYPE_MAP = {
|
||||
// Point of Sale
|
||||
'Register Not functioning properly': 269,
|
||||
'Unable to login': 275,
|
||||
'Business report issue': 267,
|
||||
'Broken device / hardware': 266,
|
||||
|
||||
// Hardware
|
||||
'Broken Device / Hardware': 266,
|
||||
'Report Missing Hardware': 273,
|
||||
'Request Additional Hardware': 274,
|
||||
|
||||
// Technology
|
||||
'Business Report Issue': 267,
|
||||
'Report an Issue with Sterling Application': 272,
|
||||
'Omni Turn Off / On': 268,
|
||||
'Report a Traffic Counter Issue': 271,
|
||||
'Report a Technology issue': 270,
|
||||
'UKG Pro / Workforce Management Issues': 426,
|
||||
'Store Transportation Request': 493,
|
||||
};
|
||||
|
||||
// SubTypes that require a storeNumber. Every currently-supported subType maps
|
||||
// to a request type whose Store Number field is `required: true` in JSM (see
|
||||
// ss-fields-*.json). Kept as an explicit set so a future subType that does NOT
|
||||
// require Store Number can be added by simply omitting it from this set.
|
||||
const SUBTYPES_REQUIRING_STORE_NUMBER = new Set(Object.keys(REQUEST_TYPE_MAP));
|
||||
|
||||
export function getSupportedSSSubTypes() {
|
||||
return Object.keys(REQUEST_TYPE_MAP);
|
||||
}
|
||||
|
||||
export { REQUEST_TYPE_MAP };
|
||||
|
||||
/**
|
||||
* Create a Store Support ticket (JSM request) using the Service Desk API.
|
||||
* @param {Object} params
|
||||
* @param {string} params.subType - Exact key from REQUEST_TYPE_MAP (e.g. "Register Not functioning properly")
|
||||
* @param {string} [params.onBehalfOf] - email or accountId (becomes raiseOnBehalfOf)
|
||||
* @param {string} params.summary
|
||||
* @param {string} [params.description]
|
||||
* @param {string|number} [params.storeNumber]
|
||||
* @param {Object} [params.additional] - extra customfield_* values merged into requestFieldValues
|
||||
*/
|
||||
export async function createSSRequest(params = {}) {
|
||||
const {
|
||||
subType,
|
||||
onBehalfOf,
|
||||
summary,
|
||||
description,
|
||||
storeNumber,
|
||||
additional = {}
|
||||
} = params;
|
||||
|
||||
if (!subType || !summary) {
|
||||
const err = new Error('subType and summary are required');
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const requestTypeId = REQUEST_TYPE_MAP[subType];
|
||||
if (!requestTypeId) {
|
||||
const err = new Error(`Unknown subType: "${subType}". Must be one of the supported values.`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Fail-fast: every current subType requires Store Number. Catching this
|
||||
// client-side gives a clean API error instead of forwarding to Jira and
|
||||
// getting back an opaque "Please provide a value for required field
|
||||
// 'Store Number'" that references Jira internals.
|
||||
const normalizedStoreNumber = storeNumber != null && String(storeNumber).trim() !== ''
|
||||
? String(storeNumber).trim()
|
||||
: null;
|
||||
if (SUBTYPES_REQUIRING_STORE_NUMBER.has(subType) && !normalizedStoreNumber) {
|
||||
const err = new Error(`storeNumber is required for subType "${subType}"`);
|
||||
err.status = 400;
|
||||
throw err;
|
||||
}
|
||||
|
||||
const serviceDeskId = config.jira.serviceDeskId || '170';
|
||||
const storeCustomField = config.jira.storeCustomFieldId || 'customfield_10261';
|
||||
|
||||
const requestFieldValues = {
|
||||
summary,
|
||||
description: description || summary,
|
||||
...additional
|
||||
};
|
||||
|
||||
if (normalizedStoreNumber) {
|
||||
const storeRef = await resolveStoreAssetReference(normalizedStoreNumber);
|
||||
if (storeRef) {
|
||||
requestFieldValues[storeCustomField] = storeRef;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
serviceDeskId: String(serviceDeskId),
|
||||
requestTypeId: String(requestTypeId),
|
||||
requestFieldValues
|
||||
};
|
||||
|
||||
if (onBehalfOf) {
|
||||
payload.raiseOnBehalfOf = onBehalfOf;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await jiraClient.post(
|
||||
'/rest/servicedeskapi/request',
|
||||
payload,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
logger.info('SS ticket created successfully', {
|
||||
issueKey: data?.issueKey,
|
||||
subType,
|
||||
storeNumber: String(storeNumber || '').padStart(5, '0')
|
||||
});
|
||||
return data;
|
||||
} catch (err) {
|
||||
const errData = err.response?.data || {};
|
||||
const message = errData.errorMessage || errData.message || err.message || 'Unknown error creating SS request';
|
||||
logger.error('Failed to create SS request', {
|
||||
subType,
|
||||
storeNumber,
|
||||
status: err.response?.status,
|
||||
details: errData
|
||||
});
|
||||
const error = new Error(message);
|
||||
error.status = err.response?.status;
|
||||
error.details = errData;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
328
src/services/jira/storesCache.js
Normal file
328
src/services/jira/storesCache.js
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
// In-memory + on-disk cache of `storeNumber → Assets objectId`.
|
||||
//
|
||||
// Populated via assetsSyncClient (personal PAT). This is the primary lookup
|
||||
// path used by resolveStoreAssetReference — the raw service-account AQL call
|
||||
// is only a fallback for the (currently-blocked) scenario where the cache is
|
||||
// unavailable and the service account has been granted enough permission to
|
||||
// query the store type directly.
|
||||
//
|
||||
// Storage:
|
||||
// In-memory: Map<paddedStoreNumber, entry>
|
||||
// On-disk: data/stores.json (see config.storesCache.path). Gitignored.
|
||||
//
|
||||
// Lifecycle:
|
||||
// init() — called at boot; loads disk cache and kicks off
|
||||
// background refresh if missing/stale, then schedules
|
||||
// periodic refresh via setInterval.
|
||||
// refresh({force}) — full paginated resync via personal PAT.
|
||||
// get(storeNumber) — sync lookup, returns entry or null.
|
||||
// status() — for the admin endpoint.
|
||||
//
|
||||
// Store objects live in ObjectType 109 in schema 68. The `label` on those
|
||||
// objects is the padded store number (verified against real objects in the
|
||||
// tenant); the `Store Number` attribute is a secondary source we fall back to.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
import { assetsAql, isAssetsSyncConfigured, describeSyncIdentity } from './assetsSyncClient.js';
|
||||
|
||||
const state = {
|
||||
cache: new Map(),
|
||||
lastSyncAt: null,
|
||||
lastError: null,
|
||||
syncing: false,
|
||||
scheduleTimer: null,
|
||||
loadedFromDisk: false,
|
||||
};
|
||||
|
||||
function normalizeStoreNumber(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const s = String(raw).trim();
|
||||
if (!s) return null;
|
||||
if (!/^\d+$/.test(s)) return null;
|
||||
return s.padStart(5, '0');
|
||||
}
|
||||
|
||||
function extractStoreNumberFromObject(obj, storeNumberAttrName) {
|
||||
// Primary: the label is the padded store number on type 109 objects.
|
||||
const label = obj?.label || obj?.name;
|
||||
if (label && /^\d+$/.test(String(label).trim())) {
|
||||
return String(label).trim().padStart(5, '0');
|
||||
}
|
||||
// Secondary: check the named attribute.
|
||||
const attrs = Array.isArray(obj?.attributes) ? obj.attributes : [];
|
||||
for (const attr of attrs) {
|
||||
const meta = attr?.objectTypeAttribute || attr?.typeAttribute || {};
|
||||
if (meta?.name === storeNumberAttrName) {
|
||||
const vals = Array.isArray(attr?.objectAttributeValues) ? attr.objectAttributeValues : [];
|
||||
for (const v of vals) {
|
||||
const cand = v?.displayValue ?? v?.value ?? v?.searchValue;
|
||||
if (cand && /^\d+$/.test(String(cand).trim())) {
|
||||
return String(cand).trim().padStart(5, '0');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync (in-memory) lookup. Returns null on miss; never throws.
|
||||
*/
|
||||
export function get(storeNumber) {
|
||||
const n = normalizeStoreNumber(storeNumber);
|
||||
if (!n) return null;
|
||||
return state.cache.get(n) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot for the admin status endpoint. Safe to serialize to JSON.
|
||||
*/
|
||||
export function status() {
|
||||
const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null;
|
||||
return {
|
||||
storeCount: state.cache.size,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
ageSeconds: lastSyncEpoch ? Math.floor((Date.now() - lastSyncEpoch) / 1000) : null,
|
||||
syncing: state.syncing,
|
||||
lastError: state.lastError,
|
||||
loadedFromDisk: state.loadedFromDisk,
|
||||
assetsSyncConfigured: isAssetsSyncConfigured(),
|
||||
syncIdentity: describeSyncIdentity(),
|
||||
cachePath: config.storesCache.path,
|
||||
refreshIntervalHours: config.storesCache.refreshIntervalHours,
|
||||
staleAfterHours: config.storesCache.staleAfterHours,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFromDisk() {
|
||||
const cachePath = config.storesCache.path;
|
||||
try {
|
||||
const raw = await fs.readFile(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
state.cache = new Map(Object.entries(parsed.entries));
|
||||
state.lastSyncAt = parsed.lastSyncAt || null;
|
||||
state.loadedFromDisk = true;
|
||||
logger.info('Loaded stores cache from disk', {
|
||||
path: cachePath,
|
||||
storeCount: state.cache.size,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
logger.warn('Stores cache file present but shape unexpected; ignoring', { path: cachePath });
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
logger.info('No stores cache on disk yet; will populate on first sync', { path: cachePath });
|
||||
} else {
|
||||
logger.warn('Failed to load stores cache from disk', { path: cachePath, error: e.message });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function saveToDisk() {
|
||||
const cachePath = config.storesCache.path;
|
||||
const dir = path.dirname(cachePath);
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const payload = {
|
||||
version: 1,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
storeCount: state.cache.size,
|
||||
entries: Object.fromEntries(state.cache),
|
||||
};
|
||||
// Atomic-ish write: to tmp then rename, avoids readers seeing a half-written file.
|
||||
const tmp = `${cachePath}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
||||
await fs.rename(tmp, cachePath);
|
||||
logger.debug('Persisted stores cache to disk', { path: cachePath, storeCount: state.cache.size });
|
||||
} catch (e) {
|
||||
logger.warn('Failed to persist stores cache to disk', { path: cachePath, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full paginated resync of all objects in the configured store object type
|
||||
* via the personal PAT. Rebuilds the in-memory cache atomically (only swaps
|
||||
* once every page has been read successfully) and then writes to disk.
|
||||
*
|
||||
* Never called reentrantly — if a refresh is already in flight, returns
|
||||
* { skipped: true, reason: 'already_syncing' }.
|
||||
*/
|
||||
export async function refresh({ force = false } = {}) {
|
||||
if (state.syncing) {
|
||||
logger.info('Stores cache refresh already in progress; ignoring duplicate call');
|
||||
return { skipped: true, reason: 'already_syncing' };
|
||||
}
|
||||
if (!isAssetsSyncConfigured()) {
|
||||
return { skipped: true, reason: 'not_configured' };
|
||||
}
|
||||
|
||||
state.syncing = true;
|
||||
state.lastError = null;
|
||||
const started = Date.now();
|
||||
const objectTypeId = config.jira.assetsStoreObjectTypeId || '109';
|
||||
const storeNumberAttr = config.jira.assetsStoreNumberAttribute || 'Store Number';
|
||||
const pageSize = config.storesCache.pageSize || 500;
|
||||
const maxPages = config.storesCache.maxPages || 200; // safety cap; 200*500 = 100k stores
|
||||
|
||||
try {
|
||||
const newCache = new Map();
|
||||
// Atlassian Assets `/object/aql` paginates via startAt + maxResults +
|
||||
// isLast. Some responses also cap `total` at 1000 as a hint, so we
|
||||
// trust isLast (or an empty/short page) rather than the reported total.
|
||||
let startAt = 0;
|
||||
let page = 0;
|
||||
let seenTotal = 0;
|
||||
let expectedTotal = null;
|
||||
let orphaned = 0;
|
||||
|
||||
while (page < maxPages) {
|
||||
const r = await assetsAql(`objectTypeId = ${objectTypeId}`, {
|
||||
startAt,
|
||||
maxResults: pageSize,
|
||||
includeAttributes: true,
|
||||
});
|
||||
|
||||
if (r.status !== 200) {
|
||||
const bodySnippet = typeof r.data === 'object'
|
||||
? JSON.stringify(r.data).slice(0, 400)
|
||||
: String(r.data).slice(0, 400);
|
||||
throw new Error(
|
||||
`Assets AQL page ${page + 1} (startAt=${startAt}) returned HTTP ${r.status} (${r.statusText}). Body: ${bodySnippet}`
|
||||
);
|
||||
}
|
||||
|
||||
const values = Array.isArray(r.data?.values) ? r.data.values : [];
|
||||
const returnedMaxResults = typeof r.data?.maxResults === 'number' ? r.data.maxResults : pageSize;
|
||||
if (expectedTotal === null && typeof r.data?.total === 'number') {
|
||||
expectedTotal = r.data.total;
|
||||
}
|
||||
|
||||
for (const obj of values) {
|
||||
const storeNumber = extractStoreNumberFromObject(obj, storeNumberAttr);
|
||||
if (!storeNumber || !obj?.id) {
|
||||
orphaned++;
|
||||
continue;
|
||||
}
|
||||
newCache.set(storeNumber, {
|
||||
objectId: String(obj.id),
|
||||
objectKey: obj.objectKey || null,
|
||||
label: obj.label || obj.name || null,
|
||||
syncedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
seenTotal += values.length;
|
||||
page++;
|
||||
|
||||
const isLast = r.data?.isLast === true
|
||||
|| values.length === 0
|
||||
|| values.length < returnedMaxResults;
|
||||
|
||||
logger.debug('Stores cache sync page', {
|
||||
page,
|
||||
startAt,
|
||||
returnedMaxResults,
|
||||
rowsThisPage: values.length,
|
||||
seenTotal,
|
||||
expectedTotal,
|
||||
mappedSoFar: newCache.size,
|
||||
orphanedSoFar: orphaned,
|
||||
isLast,
|
||||
});
|
||||
|
||||
if (isLast) break;
|
||||
startAt += values.length;
|
||||
}
|
||||
|
||||
if (page >= maxPages) {
|
||||
logger.warn('Stores cache sync hit page cap; some stores may be missing', {
|
||||
maxPages,
|
||||
pageSize,
|
||||
seenTotal,
|
||||
});
|
||||
}
|
||||
|
||||
// Only swap once we've read every page successfully.
|
||||
state.cache = newCache;
|
||||
state.lastSyncAt = new Date().toISOString();
|
||||
await saveToDisk();
|
||||
|
||||
const durationMs = Date.now() - started;
|
||||
logger.info('Stores cache refresh complete', {
|
||||
storeCount: state.cache.size,
|
||||
orphaned,
|
||||
pagesRead: page,
|
||||
durationMs,
|
||||
forced: force,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
storeCount: state.cache.size,
|
||||
orphaned,
|
||||
pagesRead: page,
|
||||
durationMs,
|
||||
};
|
||||
} catch (e) {
|
||||
state.lastError = { message: e.message, at: new Date().toISOString() };
|
||||
logger.error('Stores cache refresh failed', { error: e.message });
|
||||
throw e;
|
||||
} finally {
|
||||
state.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the disk cache, kick off a background refresh if missing or stale, and
|
||||
* schedule periodic refreshes. Idempotent — safe to call more than once.
|
||||
*
|
||||
* Never throws. Individual failures are logged and surfaced through status().
|
||||
*/
|
||||
export async function init() {
|
||||
if (!config.storesCache.enabled) {
|
||||
logger.info('Stores cache disabled via config; skipping init');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadFromDisk();
|
||||
|
||||
const staleAfterMs = (config.storesCache.staleAfterHours || 48) * 3600 * 1000;
|
||||
const shouldRefreshNow = !state.lastSyncAt
|
||||
|| (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs;
|
||||
|
||||
if (shouldRefreshNow && isAssetsSyncConfigured()) {
|
||||
logger.info('Stores cache is missing or stale; starting background refresh');
|
||||
refresh().catch(e => logger.error('Initial stores cache refresh failed', { error: e.message }));
|
||||
} else if (shouldRefreshNow) {
|
||||
logger.warn('Stores cache is missing or stale but assets sync is not configured; skipping initial refresh');
|
||||
}
|
||||
|
||||
const intervalHours = config.storesCache.refreshIntervalHours;
|
||||
if (intervalHours > 0) {
|
||||
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
|
||||
state.scheduleTimer = setInterval(() => {
|
||||
if (!isAssetsSyncConfigured()) return;
|
||||
refresh().catch(e => logger.error('Scheduled stores cache refresh failed', { error: e.message }));
|
||||
}, intervalHours * 3600 * 1000);
|
||||
state.scheduleTimer.unref?.(); // don't block process exit for tests
|
||||
logger.info('Stores cache: periodic refresh scheduled', { intervalHours });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test / shutdown hook. Clears the schedule timer and the in-memory map.
|
||||
* Does NOT delete the on-disk cache.
|
||||
*/
|
||||
export function shutdown() {
|
||||
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
|
||||
state.scheduleTimer = null;
|
||||
state.cache = new Map();
|
||||
state.lastSyncAt = null;
|
||||
state.lastError = null;
|
||||
state.syncing = false;
|
||||
state.loadedFromDisk = false;
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue