Compare commits

..

6 commits

Author SHA1 Message Date
jmcqueen
b4bc6462a3 Fix Assets sync: correct AQL pagination + Jira Cloud CMDB field format
Two bugs found during end-to-end smoke test on the stores cache
workaround (Forgejo #8):

1. Wrong pagination params on POST /object/aql. The endpoint uses
   startAt/maxResults as URL query params, not page/resultPerPage in the
   body. Passing the wrong param names caused the API to silently fall
   back to the default maxResults=25, so the first sync only cached 25
   stores. After fix: 2,661 stores paginated across 6 pages in 5.3s.

2. Wrong request-field shape for CMDB-object custom fields on Jira
   Cloud. resolveStoreAssetReference was returning
     [{ objectId: "75974" }]
   which is the legacy Data Center / Server shape. Cloud requires
     [{ id: "<workspaceId>:<objectId>" }]
   The old shape is silently accepted (HTTP 204) by REST and by
   POST /rest/servicedeskapi/request, but the field is never actually
   persisted \u2014 verified via direct REST GET showing customfield_10261:[].
   After fix: SS-20948 shows the store correctly populated.

- assetsSyncClient.js: use URLSearchParams to pass
  startAt/maxResults/includeAttributes; drop the old page/resultPerPage
  opts; jsdoc updated with pointer to Atlassian Assets API v1 docs.
- storesCache.js: pagination loop now advances by startAt +=
  values.length and trusts isLast (Atlassian caps `total` at 1000 as a
  hint on this endpoint, so we can't rely on it).
- assets.js: new buildStoreFieldRef(objectId) helper produces the
  correct Cloud shape from config.jira.assetsWorkspaceId; all three
  resolution paths (cache / live PAT / service-account fallback) route
  through it. Falls back to legacy shape with a loud error log if
  workspaceId is unset (misconfig).

End-to-end verified: SS-20948 was created for store 00782 (objectId
75974) via cache-only lookup and shows the field populated on both the
JSM request echo and a direct REST GET.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:38:28 -04:00
jmcqueen
66255a7b0c Docs: prefer .env for ASSETS_SYNC_TOKEN; Keychain is optional dev path
The bot runs on a Linux host where macOS Keychain isn't available, so .env
is the default supported storage for the personal PAT. Both paths land in
the same process.env slot, but the previous README framing implied Keychain
was mandatory.

- .env.example: promote ASSETS_SYNC_TOKEN from a comment to a real
  REPLACE_ME field; note chmod 600 and rotation guidance
- README: split the setup section into "Setup A - production/Linux (.env)"
  and "Setup B - local dev on macOS (Keychain)"; clarify that the wrapper
  is a no-op if ASSETS_SYNC_TOKEN is already exported
- bin/load-assets-sync-secret.sh: soften the header comment to match

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:22:40 -04:00
jmcqueen
fa06538aa4 Workaround #1: cache Assets store lookups via personal PAT sync
The service account is silently filtered out of Object Type 109 (Store
Address / Hierarchy) despite having schema-level read on schema 68, so
every AQL against the store type returns total=0. Until that permission
is granted, resolve store numbers from a local cache populated by a
personal PAT (different auth path, different account, has the role).

- new: src/services/jira/assetsSyncClient.js — Basic-auth axios against
  api.atlassian.com/jsm/assets/workspace/{ws}/v1, credentials sourced
  from ASSETS_SYNC_EMAIL / ASSETS_SYNC_TOKEN (loaded from Keychain by
  bin/load-assets-sync-secret.sh so the PAT never touches .env)
- new: src/services/jira/storesCache.js — in-memory Map + on-disk JSON
  at data/stores.json (gitignored), atomic write, paginated full sync
  via AQL (objectTypeId=N), boot-time load + background refresh if
  stale, periodic setInterval every STORES_CACHE_REFRESH_HOURS
- new: bin/load-assets-sync-secret.sh — Keychain -> env var wrapper
  (security find-generic-password -s jira-assets-sync -a <email>)
- change: resolveStoreAssetReference now tries cache -> live PAT -> the
  existing service-account AQL, in that order; the fallback path is
  preserved so this cleanly deactivates once the permission on #1 is
  fixed. Error message names all three routes and points at the refresh
  endpoint.
- new admin routes: GET /api/wxccai/admin/storesCache/status,
  POST /api/wxccai/admin/storesCache/refresh
- app.js kicks off storesCache.init() after listen()
- config: STORES_CACHE_ENABLED / _PATH / _REFRESH_HOURS /
  _STALE_AFTER_HOURS / _PAGE_SIZE / _MAX_PAGES, plus intFromEnv /
  boolFromEnv helpers
- .gitignore adds data/; .env.example documents the new vars; README
  adds an "Admin" endpoints section and a "Stores cache" setup guide

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 10:18:28 -04:00
jmcqueen
b8f2eab4ab Refactor #6: split jiraService.js into services/jira/{client,issues,comments,attachments,jsmRequests,assets}
Pure move + one-way rewire, no logic changes. The old 1467-line
monolithic services/jiraService.js becomes a thin barrel that re-exports
the same public surface so both current consumers keep working unchanged:
  - src/routes/wxccRoutes.js: `import * as jiraService`
  - src/services/healthService.js: `import { jiraClient }`

New module layout (deps flow one-way, no cycles):
  client.js       — jiraClient, downloadClient, plainTextToAdf (foundational)
  issues.js       — fetch/search/status/update/transitions/close
  comments.js     — fetchPublicComments, addComment, postWebexSummaryComment
  attachments.js  — attachFileToJira, attachReadableTranscript
  assets.js       — AQL, resolveStoreAssetReference, probeAssetsForStore
  jsmRequests.js  — REQUEST_TYPE_MAP, createSSRequest, subtype helpers

Verified: barrel re-exports every original name (23 named + default with
same 17 members), REQUEST_TYPE_MAP still has 14 entries, jiraClient still
instantiates against the configured baseURL, and both consumers import
without errors under the real ES module loader.

New code should import from services/jira/* directly; the barrel is only
for backward compatibility with existing callers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:36:21 -04:00
jmcqueen
312448f597 Fix #5: createSSRequest fails fast when storeNumber is missing
- New SUBTYPES_REQUIRING_STORE_NUMBER set (seeded with every current
  subType, all of which mark Store Number required in JSM). Adding a
  future subType that does NOT require Store Number is a one-line omit.
- createSSRequest now trims storeNumber (rejects whitespace-only) and
  throws a 400 with a clear message ("storeNumber is required for
  subType ...") before making any Jira API call.
- Validation errors now carry err.status = 400 so future non-route
  callers can distinguish client errors from Jira failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:30:13 -04:00
jmcqueen
c4a0a6934e Fix #4: remove global axios-retry side-effect; add dedicated downloadClient
- wxccRoutes.js no longer mutates the default axios instance, which was
  causing every bare axios call in the codebase (S3 downloads, Assets
  diagnostics) to inherit retries as a side-effect of route file load order.
- jiraService.js now defines a private downloadClient (own timeout, own
  retry policy) used by attachFileToJira and fetchAndConvertTranscript for
  fetching pre-signed S3 URLs.
- Assets AQL/GET remain bare axios calls; they're one-shot diagnostics
  and should not auto-retry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:28:47 -04:00
16 changed files with 2354 additions and 1420 deletions

View file

@ -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
View file

@ -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
# =============================================

View file

@ -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
View 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 "$@"

View file

@ -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 });
});
});

View file

@ -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,8 +70,36 @@ 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: {
apiKey: process.env.XAI_API_KEY,
baseUrl: process.env.XAI_BASE_URL || 'https://api.x.ai/v1',

View file

@ -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
View 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);
}

View 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,
};
}
}

View 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;
}
}

View 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 };
}

View 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
View 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 });
}

View 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;
}
}

View 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