Compare commits

..

No commits in common. "cursor/close-tickets" and "cursor/init-repo-and-jira-lifecycle" have entirely different histories.

24 changed files with 1420 additions and 3797 deletions

View file

@ -45,37 +45,6 @@ JIRA_ASSETS_STORE_NUMBER_ATTRIBUTE_ID=
# The custom field on the JSM request that holds the Store Assets reference. # The custom field on the JSM request that holds the Store Assets reference.
JIRA_STORE_CUSTOM_FIELD_ID=customfield_10261 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
# Shared settings for all Assets object caches (stores, business services,
# systems, causes). Each cache is a Map<key, entry> backed by a JSON file at
# ${CACHES_DIR}/${cacheName}.json. Regenerable via
# POST /api/wxccai/admin/caches/refreshAll (or refresh a single cache).
CACHES_DIR=./data
# How often to run a full resync per cache (hours). 0 disables the scheduler.
CACHES_REFRESH_HOURS=24
# Cache is considered "stale" after this many hours; boot-time refresh fires
# if the on-disk snapshot is older than this.
CACHES_STALE_AFTER_HOURS=48
# --- xAI (Grok) --- # --- xAI (Grok) ---
XAI_API_KEY=REPLACE_ME XAI_API_KEY=REPLACE_ME
XAI_BASE_URL=https://api.x.ai/v1 XAI_BASE_URL=https://api.x.ai/v1

5
.gitignore vendored
View file

@ -35,11 +35,6 @@ coverage/
tmp/ tmp/
.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 # Editor / IDE / OS
# ============================================= # =============================================

124
README.md
View file

@ -36,8 +36,7 @@ Base path: `/api/wxccai`.
### Read ### Read
- `GET /getticket?jiraKey=CS-1234` — Grok-summarized single ticket. - `GET /getticket?jiraKey=CS-1234` — Grok-summarized single ticket.
- `GET /open-tickets-by-reporter?email=user@example.com` — Grok-summarized list of open tickets a person reported. Best for corporate callers (unique per-user emails). - `GET /open-tickets-by-reporter?email=user@example.com` — Grok-summarized list of open tickets a person reported.
- `GET /open-tickets-by-store?storeNumber=00782` — Grok-summarized list of open SS tickets filed for a given store, regardless of reporter. Best for store callers (shared accounts). Store number can be unpadded; service pads to 5 digits.
- `GET /ticket/:key/status` — raw status fields (no Grok). - `GET /ticket/:key/status` — raw status fields (no Grok).
- `GET /ticket/:key/transitions` — available workflow transitions. - `GET /ticket/:key/transitions` — available workflow transitions.
@ -45,10 +44,7 @@ Base path: `/api/wxccai`.
- `PATCH /ticket/:key` — body: `{ summary?, description?, priority?, labels?, assigneeAccountId?, additional? }` - `PATCH /ticket/:key` — body: `{ summary?, description?, priority?, labels?, assigneeAccountId?, additional? }`
- `POST /ticket/:key/comment` — body: `{ text, internal? }` - `POST /ticket/:key/comment` — body: `{ text, internal? }`
- `POST /ticket/:key/close` — body: `{ transitionName?, resolution?, comment?, internal?, component?, businessService?, system?, cause?, subType?, preserveExistingClassification?, skipValidatorFields? }`. Auto-picks the first "done" transition when `transitionName` is omitted. When the chosen transition is done-category, the four SS workflow-validator fields (`components`, `Business Service`, `System`, `Cause`) are populated first (see "Closing SS tickets" below). Non-done transitions skip the resolution field (fixes issue #9). - `POST /ticket/:key/close` — body: `{ transitionName?, resolution?, comment?, internal? }` (auto-picks the first "done" transition when `transitionName` omitted).
- `POST /ticket/:key/confirmFixed` — CC-agent convenience. Body: `{ subType?, comment?, component?, businessService?, system?, cause?, internal? }`. Closes with `resolution=Done`.
- `POST /ticket/:key/customerCancelled` — CC-agent convenience. Body: `{ subType?, reason?, component?, businessService?, system?, cause?, internal? }`. Closes with `resolution=Won't Do`.
- `POST /ticket/:key/duplicate` — CC-agent convenience. Body: `{ primaryKey (required), subType?, comment?, component?, businessService?, system?, cause?, internal? }`. Creates a formal `Duplicate` issue link to `primaryKey`, then closes with `resolution=Duplicate`.
### Store Support (JSM requests) ### Store Support (JSM requests)
@ -59,126 +55,10 @@ Base path: `/api/wxccai`.
- `POST /issueTranscript/:jiraKey` — attaches audio + JSON transcript + human-readable transcript, then posts a restricted-visibility summary comment. - `POST /issueTranscript/:jiraKey` — attaches audio + JSON transcript + human-readable transcript, then posts a restricted-visibility summary comment.
### Admin
- `GET /admin/caches` — snapshot of all four Assets object caches (stores, businessServices, systems, causes): `{ caches: [{ name, count, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }] }`. Safe for health checks.
- `GET /admin/caches/:name/status` — same shape, one cache.
- `POST /admin/caches/:name/refresh` — force an immediate resync of one cache via the personal PAT (see below). Takes a few seconds.
- `POST /admin/caches/refreshAll` — refresh every cache in parallel.
- `GET /admin/storesCache/status` and `POST /admin/storesCache/refresh` — backward-compat aliases for the stores cache endpoints above.
### Debug (non-production only) ### 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`. - `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`.
## Assets object caches (Assets workaround)
Several parts of the SS lifecycle need to translate a human-readable name (a store number, a Business Service name, a System name, a Cause code) into a Jira Assets **object id** before the value can be written to a CMDB custom field. The shared service account is silently filtered out of the underlying object schema (68), so the app maintains four local caches that are populated from a **personal Atlassian PAT** (a real human account with the right Assets role):
| Cache | Object type | Used for |
| ------------------ | ----------- | ------------------------------------------------------------------- |
| `stores` | 109 | `customfield_10261 Store Number` on new SS tickets |
| `businessServices` | 100 | `customfield_10224 Business Service` workflow validator on close |
| `systems` | 103 | `customfield_10225 System` workflow validator on close |
| `causes` | 107 | `customfield_10233 Cause` workflow validator on close |
All four are backed by the same shared factory (`services/jira/assetsObjectCache.js`) and use the same PAT credentials. The service account is still used for everything else (creating tickets, comments, attachments, transitions).
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 each on-disk cache from `$CACHES_DIR/{name}.json` (default `./data/`), kicks off a background refresh for any snapshot missing or older than `CACHES_STALE_AFTER_HOURS`, and schedules a periodic full resync every `CACHES_REFRESH_HOURS`. `resolveStoreAssetReference` serves lookups from memory (sub-ms) with a live PAT lookup as fallback for brand-new stores. The three close-time caches (business services, systems, causes) are much smaller (dozens to a few hundred entries) and rarely change.
Force a refresh at any time:
```bash
curl -X POST http://localhost:1866/api/wxccai/admin/caches/stores/refresh
curl -X POST http://localhost:1866/api/wxccai/admin/caches/refreshAll
```
## Closing SS tickets
The `Resolved` transition on SS tickets fires a workflow validator that requires four fields to be populated:
- `components` — Jira native (63 options in the SS project)
- `customfield_10224` Business Service — Assets CMDB
- `customfield_10225` System — Assets CMDB
- `customfield_10233` Cause — Assets CMDB (the value "Unknown" exists as a designed catch-all)
`closeTicket` fills these in **before** calling the Resolved transition. Resolution order per field:
1. Explicit value in the request body (`component`, `businessService`, `system`, `cause`)
2. Whatever is already on the ticket (if `preserveExistingClassification` is `true`, the default — respects human triage)
3. The per-subType default from `src/config/ssCloseDefaults.js`
4. The `__default__` entry (Help Desk / Store Technology / I can't find my option - Misc / Unknown)
For CC-agent-driven closes, the shortest path is to send just `subType` and `comment`; everything else is defaulted. Use the convenience routes:
```bash
# Caller confirms the issue is resolved
curl -X POST http://localhost:1866/api/wxccai/ticket/SS-20948/confirmFixed \
-H 'Content-Type: application/json' \
-d '{"subType":"Report a Technology issue"}'
# Caller wants to cancel
curl -X POST http://localhost:1866/api/wxccai/ticket/SS-20949/customerCancelled \
-H 'Content-Type: application/json' \
-d '{"subType":"Broken device / hardware","reason":"changed their mind"}'
# Duplicate of an earlier ticket (creates a formal Duplicate issueLink)
curl -X POST http://localhost:1866/api/wxccai/ticket/SS-20950/duplicate \
-H 'Content-Type: application/json' \
-d '{"primaryKey":"SS-20948"}'
```
To override the auto-detected classification (for a subType not in the defaults map, or when the caller volunteers specific context), pass any of the four fields explicitly. CMDB names are matched case-insensitively; you can also pass a raw Assets objectId as a shortcut for `businessService` / `system` / `cause`:
```bash
curl -X POST http://localhost:1866/api/wxccai/ticket/SS-XXXXX/confirmFixed \
-H 'Content-Type: application/json' \
-d '{"subType":"Broken device / hardware","system":"Printer","cause":"Broken Equipment"}'
```
## Jira Assets gotcha ## 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`: `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`:

View file

@ -1,78 +0,0 @@
#!/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

@ -1,378 +0,0 @@
# WxCC AI Agent — Ticket Operations Reference
Everything the Webex Contact Center AI agent needs to open, look up, comment
on, and close AEO Store Support (SS) Jira tickets via this service. Includes:
1. The full inventory of ticket-operation endpoints
2. The 14 supported SS subTypes
3. A drop-in system prompt for the AI agent
4. Tool definitions in OpenAI/JSON-schema function-calling format
5. Two worked call examples
6. Endpoints that are deliberately NOT exposed to the AI
All endpoints are under `/api/wxccai/` on the service (default port `1866`).
This doc covers the caller-facing surface only — admin/debug endpoints
(`/admin/caches/*`, `/debug/*`) and the Webex transcript webhook
(`/issueTranscript/:jiraKey`) are intentionally omitted here.
---
## 1. Ticket-operation inventory
### Read / lookup (safe, side-effect-free)
| # | Tool | Endpoint | What it does |
| - | --------------------- | --------------------------------------------------- | ------------ |
| 1 | `lookupTicket` | `GET /getticket?jiraKey=SS-12345` | Returns an **AI-summarized** view (Grok-generated) of a specific ticket: status, assignee, and a natural-language recap of the description + last 5 comments. Best for "tell me about my ticket" moments. |
| 2 | `findMyStoreTickets` | `GET /open-tickets-by-store?storeNumber=00782` | Returns every currently open SS ticket filed **for a given store**, regardless of who reported it. This is the right lookup for store callers because associates typically share accounts / iPads — a ticket opened by a coworker would not show up in `findMyTickets` (which is keyed by reporter email). |
| 3 | `findMyTickets` | `GET /open-tickets-by-reporter?email=user@ae.com` | Returns the caller's currently open tickets across CS/SS/SUPPORT with short summaries. Best for **corporate** callers (developers, ops, HQ staff) whose email uniquely identifies them. For **store** callers, prefer `findMyStoreTickets`. |
| 4 | `getTicketStatus` | `GET /ticket/:key/status` | Raw status fields (no Grok). Faster/cheaper than `lookupTicket`. Use when the AI just needs the current status, resolution, assignee — not a narrative. |
### Create (opens a new ticket)
| # | Tool | Endpoint | What it does |
| - | ------------------- | ---------------------------- | ------------ |
| 5 | `createStoreTicket` | `POST /createSSRequest` | Files a new Store Support (SS) ticket with the right subType, links it to a store via Assets, and returns the new key. Uses the 14 supported subTypes below. |
### Update (mid-conversation)
| # | Tool | Endpoint | What it does |
| - | --------------------- | ---------------------------------------- | ------------ |
| 6 | `addTicketComment` | `POST /ticket/:key/comment` | Appends a comment (public or `internal:true`). Internal is the safe default for AI-authored notes. |
| 7 | `updateTicketFields` | `PATCH /ticket/:key` | Update summary / description / priority / labels / assignee / custom fields. Rarely needed by the AI. |
### Close (three intent-specific + one full-control)
| # | Tool | Endpoint | What it does |
| - | ------------------------ | --------------------------------------------- | ------------ |
| 8 | `confirmTicketFixed` | `POST /ticket/:key/confirmFixed` | Caller says the issue is resolved → close with `resolution=Done` + audit comment. |
| 9 | `cancelTicket` | `POST /ticket/:key/customerCancelled` | Caller wants to abandon the request → close with `resolution=Won't Do`. |
| 10 | `markTicketDuplicate` | `POST /ticket/:key/duplicate` | Caller already has another ticket for the same issue → creates a formal `Duplicate` issueLink to the primary + closes with `resolution=Duplicate`. |
| — | `closeTicket` | `POST /ticket/:key/close` | Full-control close. Do NOT expose to the AI — use the intent-specific tools instead. |
All three close tools auto-populate the four workflow-validator fields
(Component, Business Service, System, Cause) from `subType`-based defaults
(see `src/config/ssCloseDefaults.js`), so the AI just supplies the ticket
key and the subType from the original create.
---
## 2. Supported SS subTypes (must match exactly)
The AI must pass one of these strings, unchanged. Casing and spacing
matter; duplicates in the list are intentional (both spellings are
mapped, but the AI should still copy exactly one of these strings).
```
Register Not functioning properly
Unable to login
Business report issue
Broken device / hardware
Broken Device / Hardware
Report Missing Hardware
Request Additional Hardware
Business Report Issue
Report an Issue with Sterling Application
Omni Turn Off / On
Report a Traffic Counter Issue
Report a Technology issue
UKG Pro / Workforce Management Issues
Store Transportation Request
```
If none of these clearly fit the caller's issue, use
**`Report a Technology issue`** as the catch-all.
---
## 3. System prompt (paste into the WxCC AI Agent config)
```text
You are a Store Support (SS) AI agent for AEO stores. You handle inbound
calls from store associates and managers who need technology help. Your job
is to identify the caller, understand their issue, and either open, update,
or close the appropriate Jira ticket.
CORE PRINCIPLES
1. Never invent ticket keys, store numbers, or subTypes. If the caller
doesn't volunteer one, ask.
2. Store numbers are always 5 digits (pad with leading zeros: "305" →
"00305"). The service pads unpadded input for you but always spell
the padded form back to the caller ("store zero-zero-three-zero-five").
3. Never call a close tool (confirmTicketFixed, cancelTicket,
markTicketDuplicate) based on inference alone. The caller must
explicitly state the intent in the current turn.
4. When in doubt, add an internal comment (addTicketComment with
internal=true) and escalate to a human. Never close a ticket you're
unsure about — an open ticket costs the business less than a wrongly
closed one.
5. Identify callers correctly: STORE callers (associates, managers, from
a store location) → use findMyStoreTickets keyed by store number,
because store accounts are frequently shared. CORPORATE callers
(developers, ops, HQ staff) → use findMyTickets keyed by their email,
because email uniquely identifies them.
TYPICAL CALL FLOW
Step 1: Identify the caller and find existing tickets.
- Ask "Are you calling from a store, or are you calling from
corporate?" if you can't tell from context.
- If STORE: get the store number, then call findMyStoreTickets with
the padded store number. This returns EVERY open SS ticket for that
store — including ones opened by their coworkers.
- If CORPORATE: get their email, then call findMyTickets with the
email. This returns tickets they personally reported across CS/SS/
SUPPORT.
- You may call BOTH for a store caller who also has a personal work
email — the union catches everything.
Step 2: Decide what they need.
- If they reference a specific existing ticket → lookupTicket for a
summary; then decide with them what to do next (comment, close, or
just answer their question).
- If they describe a new issue → pick the best subType from the fixed
list (see the createStoreTicket tool description) and call
createStoreTicket.
- If it's a mix ("my existing ticket X is fixed but now Y is broken")
→ handle in order: close the resolved one first, then create the new
one.
Step 3: Close at the right moment, with the right tool.
- "It's already fixed" / "the manager rebooted it and it works now"
→ confirmTicketFixed
- "Never mind" / "cancel it" / "I don't need this anymore"
→ cancelTicket
- "I already opened SS-12345 for this" / "this is the same as SS-XXX"
→ markTicketDuplicate (you must have the primaryKey)
- Anything ambiguous → addTicketComment describing the ambiguity and
escalate.
Step 4: Always confirm the action back to the caller.
- After createStoreTicket, read back the new ticket key.
- After any close tool, tell them the ticket is closed and why.
WHAT NOT TO DO
- Do NOT expose Jira internals (customfield IDs, objectTypeIds, workflow
transition IDs) to the caller.
- Do NOT call closeTicket (the raw endpoint). Always use one of the three
intent-specific tools.
- Do NOT call updateTicketFields unless the caller explicitly asks to
change something structural (priority, assignee).
- Do NOT re-open a ticket you just closed in the same call. If you closed
by mistake, escalate to a human.
```
---
## 4. Tool definitions (OpenAI / JSON-schema function-calling format)
Adjust the wrapper syntax to whatever WxCC expects; the JSON schema is
standard and portable across most LLM tool-calling frameworks.
```json
[
{
"name": "findMyStoreTickets",
"description": "Return every currently open SS ticket for a given store, regardless of who reported it. This is the RIGHT tool for store callers (associates, managers) because store accounts and iPads are typically shared — a ticket opened by their coworker earlier in the shift will NOT show up in findMyTickets (which is keyed by reporter email). Call this near the start of every store call once you have the store number.",
"parameters": {
"type": "object",
"properties": {
"storeNumber": { "type": "string", "description": "Store number, numeric. May be unpadded ('782') or padded ('00782') — the service normalizes to 5 digits." }
},
"required": ["storeNumber"]
},
"http": { "method": "GET", "path": "/api/wxccai/open-tickets-by-store", "queryParams": ["storeNumber"] }
},
{
"name": "findMyTickets",
"description": "Look up the caller's currently open tickets across CS/SS/SUPPORT projects, keyed by their email. Best for CORPORATE callers (developers, ops, HQ staff) whose email uniquely identifies them. For STORE callers, prefer findMyStoreTickets — store accounts are typically shared and reporter-email search will miss tickets a coworker opened.",
"parameters": {
"type": "object",
"properties": {
"email": { "type": "string", "description": "Caller's work email address (e.g. someone@ae.com)." }
},
"required": ["email"]
},
"http": { "method": "GET", "path": "/api/wxccai/open-tickets-by-reporter", "queryParams": ["email"] }
},
{
"name": "lookupTicket",
"description": "Fetch an AI-summarized view of a specific ticket (title, status, assignee, and a natural-language recap of the description + last 5 comments). Use when the caller references an existing ticket by key and you need context before taking action.",
"parameters": {
"type": "object",
"properties": {
"jiraKey": { "type": "string", "description": "Jira issue key, e.g. SS-20948." }
},
"required": ["jiraKey"]
},
"http": { "method": "GET", "path": "/api/wxccai/getticket", "queryParams": ["jiraKey"] }
},
{
"name": "getTicketStatus",
"description": "Get raw status/assignee/resolution/priority fields for a ticket without the AI summary. Faster than lookupTicket. Use when you just need to check whether a ticket is Open/In Progress/Resolved.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "Jira issue key, e.g. SS-20948." }
},
"required": ["key"]
},
"http": { "method": "GET", "path": "/api/wxccai/ticket/{key}/status", "pathParams": ["key"] }
},
{
"name": "createStoreTicket",
"description": "Open a new Store Support (SS) ticket. Use ONLY when the caller has a new issue that isn't already covered by one of their existing open tickets (check findMyTickets first). Choose the subType that best matches the caller's issue from the fixed list. If none of the listed subTypes fit, use 'Report a Technology issue' as the catch-all.",
"parameters": {
"type": "object",
"properties": {
"subType": {
"type": "string",
"enum": [
"Register Not functioning properly",
"Unable to login",
"Business report issue",
"Broken device / hardware",
"Broken Device / Hardware",
"Report Missing Hardware",
"Request Additional Hardware",
"Business Report Issue",
"Report an Issue with Sterling Application",
"Omni Turn Off / On",
"Report a Traffic Counter Issue",
"Report a Technology issue",
"UKG Pro / Workforce Management Issues",
"Store Transportation Request"
],
"description": "Exact subType string. Must match one of the enum values."
},
"onBehalfOf": { "type": "string", "description": "Caller's email address." },
"summary": { "type": "string", "description": "One-line title of the issue. Concise, action-oriented." },
"description":{ "type": "string", "description": "Detailed narrative. Include what the caller was doing, what went wrong, and any error messages they read to you." },
"storeNumber":{ "type": "string", "description": "5-digit store number (pad with leading zeros: '305' -> '00305')." }
},
"required": ["subType", "onBehalfOf", "summary", "storeNumber"]
},
"http": { "method": "POST", "path": "/api/wxccai/createSSRequest", "body": "json" }
},
{
"name": "addTicketComment",
"description": "Append a comment to an existing ticket. Use to record mid-call context, escalation reasons, or anything the caller says that a downstream human agent will need. Default to internal=true for AI-authored notes; only set internal=false when the caller has asked for a public/customer-visible update.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "Jira issue key." },
"text": { "type": "string", "description": "Comment body, plain text." },
"internal": { "type": "boolean", "description": "true = visible only to the Service Desk Team (default, recommended). false = visible to the customer.", "default": true }
},
"required": ["key", "text"]
},
"http": { "method": "POST", "path": "/api/wxccai/ticket/{key}/comment", "pathParams": ["key"], "body": "json" }
},
{
"name": "confirmTicketFixed",
"description": "Close a ticket because the caller has EXPLICITLY confirmed the underlying issue is resolved (e.g. 'the manager rebooted it and it works now', 'yeah I got it working'). Do not use this to close a ticket the caller is ambivalent or silent about. Adds a standardized 'Closed via WxCC AI agent: caller confirmed the issue is resolved.' internal comment automatically.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "Jira issue key being closed." },
"subType": { "type": "string", "description": "The original subType the ticket was opened under. Same enum as createStoreTicket. Used to pick the right classification defaults for the workflow validator." },
"comment": { "type": "string", "description": "Optional extra context to append to the standardized close comment." }
},
"required": ["key", "subType"]
},
"http": { "method": "POST", "path": "/api/wxccai/ticket/{key}/confirmFixed", "pathParams": ["key"], "body": "json" }
},
{
"name": "cancelTicket",
"description": "Close a ticket because the caller has EXPLICITLY asked to cancel it (e.g. 'nevermind, cancel it', 'I don't need this anymore', 'I opened this by mistake'). Records the caller's reason if they gave one. Resolution is set to 'Won't Do'.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "Jira issue key being cancelled." },
"subType": { "type": "string", "description": "The original subType. Used to pick classification defaults." },
"reason": { "type": "string", "description": "Optional reason the caller gave for the cancellation." }
},
"required": ["key", "subType"]
},
"http": { "method": "POST", "path": "/api/wxccai/ticket/{key}/customerCancelled", "pathParams": ["key"], "body": "json" }
},
{
"name": "markTicketDuplicate",
"description": "Close a ticket because the caller told you it's a duplicate of another existing ticket. Requires BOTH the ticket being closed (key) AND the primary ticket it duplicates (primaryKey). Creates a formal 'Duplicate' link between them in Jira, then closes with resolution 'Duplicate'. If the caller isn't sure of the primary key, do NOT guess — use findMyTickets to help them identify it, or fall back to addTicketComment + escalate.",
"parameters": {
"type": "object",
"properties": {
"key": { "type": "string", "description": "Jira key of the ticket being closed as a duplicate." },
"primaryKey": { "type": "string", "description": "Jira key of the primary/canonical ticket this one duplicates." },
"subType": { "type": "string", "description": "The original subType of the duplicate ticket. Used to pick classification defaults." },
"comment": { "type": "string", "description": "Optional extra context to append to the standardized close comment." }
},
"required": ["key", "primaryKey", "subType"]
},
"http": { "method": "POST", "path": "/api/wxccai/ticket/{key}/duplicate", "pathParams": ["key"], "body": "json" }
}
]
```
---
## 5. Worked call examples
### Example 1 — store caller, checks store history first, then creates
```
Caller: "Hi, register 3 at store 782 froze during checkout."
AI: [findMyStoreTickets storeNumber="782"]
→ returns [SS-20380 (wireless phone), SS-11943 (Zipline training)]
(Both open, but neither about the register. Nothing to reuse.)
AI: "I don't see any existing tickets for this issue at your store.
Let me open a new one. Can I get your email for the ticket?"
Caller: "jane@ae.com"
AI: [createStoreTicket
subType="Register Not functioning properly",
onBehalfOf="jane@ae.com",
summary="Register 3 froze at checkout",
description="Caller reports register 3 became unresponsive during a
transaction around 10:15am. No error message read to
the caller.",
storeNumber="00782"]
→ returns { issueKey: "SS-20955" }
AI: "I've opened ticket SS-20955 for you. The store technology team
will pick it up shortly."
```
Note how `findMyStoreTickets` surfaces tickets opened by *other* people at
the store (SS-20380, SS-11943). If Jane had called `findMyTickets` with
just her email, she'd have missed both.
### Example 2 — caller wants to close an existing ticket
```
Caller: "Hey, I opened SS-20948 yesterday but the manager fixed it — can
you close it out?"
AI: [getTicketStatus key="SS-20948"] → status: "Open"
AI: "Just to confirm, SS-20948 for the [issue description] — the
issue is fully resolved and you don't need us to look at it
anymore?"
Caller: "Yep, all good."
AI: [confirmTicketFixed
key="SS-20948",
subType="Report a Technology issue",
comment="Caller reports issue resolved on-site by store manager."]
→ returns { success: true, transitionUsed: "Resolved", resolution: "Done" }
AI: "Perfect, SS-20948 is now closed as resolved. Anything else?"
```
---
## 6. Deliberately NOT exposed to the AI
Reserved for humans / ops / internal plumbing:
- `updateTicketFields` (`PATCH /ticket/:key`) — too much surface area; humans should handle structural edits.
- `closeTicket` (raw `POST /ticket/:key/close`) — the three intent-specific tools are safer; they auto-populate the workflow validator and audit comment.
- `getTransitions` (`GET /ticket/:key/transitions`) — internal plumbing; the close tools use it under the hood.
- `/admin/caches/*`, `/debug/*` — cache and diagnostic endpoints.
- `/issueTranscript/:jiraKey` — Webex Contact Center webhook target, called by the platform rather than by the AI agent's tool loop.

View file

@ -2,7 +2,6 @@ import express from 'express';
import config from './config/index.js'; import config from './config/index.js';
import wxccRoutes from './routes/wxccRoutes.js'; import wxccRoutes from './routes/wxccRoutes.js';
import { getDetailedHealth } from './services/healthService.js'; import { getDetailedHealth } from './services/healthService.js';
import { allCaches } from './services/jira/caches.js';
import logger from './utilities/logger.js'; import logger from './utilities/logger.js';
const app = express(); const app = express();
@ -107,8 +106,6 @@ app.get('/', (req, res) => {
closeTicket: "POST /api/wxccai/ticket/:key/close body: { transitionName?, resolution?, comment?, internal? }", 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)", 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)", 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)" assetsProbe: "GET /api/wxccai/debug/assetsProbe?storeNumber=305 (non-production only)"
} }
}); });
@ -134,14 +131,4 @@ app.use((req, res) => {
app.listen(config.port, () => { app.listen(config.port, () => {
logger.info(`Server running on port ${config.port} in ${config.nodeEnv} mode`); logger.info(`Server running on port ${config.port} in ${config.nodeEnv} mode`);
// Kick off all Assets object caches: load from disk, schedule periodic
// refresh, and (if stale) start a background full refresh. Never blocks
// startup. Each cache runs independently — failure of one doesn't stop
// the others.
for (const cache of allCaches) {
cache.init().catch(err => {
logger.error(`cache.init failed at boot`, { cache: cache.name, error: err.message });
});
}
}); });

View file

@ -1,23 +1,9 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'node:path';
dotenv.config(); dotenv.config();
const cloudId = process.env.JIRA_CLOUD_ID?.trim(); const cloudId = process.env.JIRA_CLOUD_ID?.trim();
let baseUrl = process.env.JIRA_BASE_URL?.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) { if (cloudId) {
// When JIRA_CLOUD_ID is provided, use the api.atlassian.com/ex/jira gateway form. // 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 // This is the current style for addressing a specific Jira Cloud site (e.g. for
@ -70,37 +56,6 @@ export const config = {
// The custom field ID on the request that holds the Store Assets reference // The custom field ID on the request that holds the Store Assets reference
storeCustomFieldId: process.env.JIRA_STORE_CUSTOM_FIELD_ID || 'customfield_10261', 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,
},
// Shared config for all Assets object caches (stores, business services,
// systems, causes). Each cache is a `Map<key, entry>` backed by a JSON
// file on disk at `${dir}/${cacheName}.json`. All caches use the same
// personal PAT auth path (see jira.assetsSyncToken above).
caches: {
enabled: boolFromEnv('CACHES_ENABLED', true),
// Directory where on-disk snapshots live. Filenames are auto-derived
// per cache: stores.json, businessServices.json, systems.json,
// causes.json. Gitignored via data/ in .gitignore.
dir: process.env.CACHES_DIR?.trim()
|| path.resolve(process.cwd(), 'data'),
refreshIntervalHours: intFromEnv('CACHES_REFRESH_HOURS', 24),
staleAfterHours: intFromEnv('CACHES_STALE_AFTER_HOURS', 48),
pageSize: intFromEnv('CACHES_PAGE_SIZE', 500),
maxPages: intFromEnv('CACHES_MAX_PAGES', 200),
}, },
xai: { xai: {

View file

@ -1,142 +0,0 @@
// Per-subType default classifications for closing SS tickets.
//
// The Resolved (161) transition has a workflow validator that requires four
// fields to be set on the ticket before it can complete:
// - components (Jira native, 63 options)
// - Business Service (CMDB customfield_10224)
// - System (CMDB customfield_10225)
// - Cause (CMDB customfield_10233)
//
// Only the values that ACTUALLY exist in the tenant (as verified against the
// live caches on 2026-07-07) are used here — otherwise the close will fail
// with the "please select relevant …" workflow validator error.
//
// Resolution order used by closeTicket (see issues.js closeTicket):
// 1. Caller-supplied value in the request body
// 2. Whatever's already on the ticket (respects human triage)
// 3. The subType-specific default from this file (below)
// 4. The __default__ entry, if the subType isn't listed
// 5. Fail with a clear error if step 4 also has an empty slot
//
// All values are matched case-insensitively via the caches' normalizeKey.
export const SS_CLOSE_DEFAULTS = {
// Fallback for any subType not listed below. Uses the tenant's designed
// "I don't know" escape valves ("Unknown" cause, "I can't find my option
// - Misc" system) plus Help Desk as the safest component (that queue
// does further triage).
__default__: {
component: 'Help Desk',
businessService: 'Store Technology',
system: "I can't find my option - Misc",
cause: 'Unknown',
},
// ----- Point of Sale / Register -----
'Register Not functioning properly': {
component: 'Store Platform',
businessService: 'Fixed Register',
system: 'Oracle Point of Sale (POS)',
cause: 'Unknown',
},
'Unable to login': {
component: 'Identity Platform',
businessService: 'Store Technology',
system: 'Login',
cause: 'Access Expired',
},
// ----- Business reporting -----
'Business report issue': {
component: 'Store Operations Technology',
businessService: 'Business Reporting',
system: 'Reporting',
cause: 'Unknown',
},
'Business Report Issue': {
component: 'Store Operations Technology',
businessService: 'Business Reporting',
system: 'Reporting',
cause: 'Unknown',
},
// ----- Hardware -----
'Broken device / hardware': {
component: 'Store Technology Experience',
businessService: 'Store Technology',
system: 'Peripheral Devices',
cause: 'Broken Equipment',
},
'Broken Device / Hardware': {
component: 'Store Technology Experience',
businessService: 'Store Technology',
system: 'Peripheral Devices',
cause: 'Broken Equipment',
},
'Report Missing Hardware': {
component: 'Store Technology Experience',
businessService: 'Store Technology',
system: 'Peripheral Devices',
cause: 'Missing/Stolen Equipment',
},
'Request Additional Hardware': {
component: 'Store Technology Experience',
businessService: 'Store Technology',
system: 'Peripheral Devices',
cause: 'New Request',
},
// ----- Technology (misc) -----
'Report a Technology issue': {
component: 'Help Desk',
businessService: 'Store Technology',
system: "I can't find my option - Misc",
cause: 'Unknown',
},
'Report an Issue with Sterling Application': {
component: 'Sterling Ops',
businessService: 'Store Technology',
system: 'Sterling Application',
cause: 'Unknown',
},
'Omni Turn Off / On': {
component: 'Omni Operations',
businessService: 'Store Technology',
system: 'On/Off Request',
cause: 'New Request',
},
'Report a Traffic Counter Issue': {
component: 'Store Technology Experience',
businessService: 'Store Technology',
system: 'RetailNext Traffic',
cause: 'Unknown',
},
// ----- UKG / Workforce Management -----
// Verified from real closed tickets SS-20272, SS-20022, SS-19863 which
// all used exactly this shape.
'UKG Pro / Workforce Management Issues': {
component: 'UKG_COE',
businessService: 'Store Technology',
system: 'UKG Pro WFM',
cause: 'Unknown',
},
// ----- Store Transportation -----
'Store Transportation Request': {
component: 'Transportation',
businessService: 'Store Technology',
system: 'Transportation',
cause: 'New Request',
},
};
/**
* Get the defaults for a subType, falling back to the __default__ entry.
* Returns a plain object; callers may safely mutate the returned object.
*/
export function getDefaultsForSubType(subType) {
const specific = SS_CLOSE_DEFAULTS[subType];
if (specific) return { ...specific };
return { ...SS_CLOSE_DEFAULTS.__default__ };
}

View file

@ -1,9 +1,22 @@
import express from 'express'; import express from 'express';
import axios from 'axios';
import axiosRetry from 'axios-retry';
import { logger, webexLogger } from '../utilities/logger.js'; import { logger, webexLogger } from '../utilities/logger.js';
import * as jiraService from '../services/jiraService.js'; import * as jiraService from '../services/jiraService.js';
import grokService from '../services/grokService.js'; import grokService from '../services/grokService.js';
import config from '../config/index.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(); const router = express.Router();
// ======================== // ========================
@ -111,35 +124,6 @@ router.get('/wxccai/open-tickets-by-reporter', async (req, res) => {
} }
}); });
// ========================
// Open Tickets by Store
// ========================
// Store associates share accounts / iPads, so reporter-email search misses
// tickets opened by a coworker. This endpoint finds every open SS ticket
// filed for the given store, regardless of who reported it.
router.get('/wxccai/open-tickets-by-store', async (req, res) => {
const raw = req.query.storeNumber?.trim();
if (!raw || !/^\d+$/.test(raw)) {
return res.status(400).json({
error: "Invalid or missing storeNumber",
message: "Please provide a numeric storeNumber (will be padded to 5 digits)"
});
}
try {
const issues = await jiraService.searchOpenTicketsByStoreNumber(raw);
const ticketArray = await grokService.generateOpenTicketsSummary(issues);
res.status(200).json(ticketArray);
} catch (error) {
logger.error(`Open tickets by store failed for ${raw}:`, error);
// Same degrade-gracefully pattern as the reporter route.
res.status(200).json([]);
}
});
// ======================== // ========================
// Store Support (SS) Ticket Creation // Store Support (SS) Ticket Creation
// ======================== // ========================
@ -317,41 +301,20 @@ router.get('/wxccai/ticket/:key/transitions', async (req, res) => {
} }
}); });
// Body: { // Body: { transitionName?, resolution?, comment?, internal? }
// transitionName?, // explicit override; else auto-pick a "done" transition // If transitionName is omitted, we auto-pick the first "done" transition.
// resolution?, // "Done" | "Won't Do" | "Duplicate" | "Fixed" | ... (only used for done-category transitions)
// comment?, // audit-trail comment
// internal?, // comment visibility
// component?, // Jira component name (workflow validator)
// businessService?, // CMDB name or objectId (workflow validator)
// system?, // CMDB name or objectId (workflow validator)
// cause?, // CMDB name or objectId (workflow validator; usually "Unknown")
// subType?, // SS request subType; used to look up defaults from ssCloseDefaults.js
// preserveExistingClassification?, // default true; if false, overwrites anything already on the ticket
// skipValidatorFields? // if true, skip the pre-transition field write
// }
router.post('/wxccai/ticket/:key/close', async (req, res) => { router.post('/wxccai/ticket/:key/close', async (req, res) => {
const key = req.params.key?.trim().toUpperCase(); const key = req.params.key?.trim().toUpperCase();
if (!key || !KEY_RE.test(key)) { if (!key || !KEY_RE.test(key)) {
return res.status(400).json({ error: 'Invalid Jira key' }); return res.status(400).json({ error: 'Invalid Jira key' });
} }
const body = req.body || {}; const { transitionName, resolution, comment, internal } = req.body || {};
try { try {
const result = await jiraService.closeTicket(key, { const result = await jiraService.closeTicket(key, {
transitionName: body.transitionName, transitionName,
// Deliberately NOT defaulting to 'Done' here anymore — closeTicket() resolution: resolution || 'Done',
// will only send a resolution when the chosen transition is comment,
// done-category (fixes bug #9). internal: !!internal
resolution: body.resolution || undefined,
comment: body.comment,
internal: !!body.internal,
component: body.component,
businessService: body.businessService,
system: body.system,
cause: body.cause,
subType: body.subType,
preserveExistingClassification: body.preserveExistingClassification !== false,
skipValidatorFields: !!body.skipValidatorFields,
}); });
res.json({ success: true, ...result }); res.json({ success: true, ...result });
} catch (err) { } catch (err) {
@ -362,152 +325,6 @@ router.post('/wxccai/ticket/:key/close', async (req, res) => {
} }
}); });
// Contact Center convenience wrappers over /close. Each takes a small body
// with just the intent-specific fields; the rest is defaulted from subType.
//
// Body: { comment?, subType?, component?, businessService?, system?, cause?, internal? }
router.post('/wxccai/ticket/:key/confirmFixed', async (req, res) => {
const key = req.params.key?.trim().toUpperCase();
if (!key || !KEY_RE.test(key)) {
return res.status(400).json({ error: 'Invalid Jira key' });
}
try {
const result = await jiraService.confirmFixed(key, req.body || {});
res.json({ success: true, ...result });
} catch (err) {
logger.error('confirmFixed route failed', { key, error: err.message, details: err.details });
res.status(err.status && err.status < 500 ? err.status : 500).json({
success: false, error: err.message, details: err.details || null
});
}
});
// Body: { reason?, subType?, component?, businessService?, system?, cause?, internal? }
router.post('/wxccai/ticket/:key/customerCancelled', async (req, res) => {
const key = req.params.key?.trim().toUpperCase();
if (!key || !KEY_RE.test(key)) {
return res.status(400).json({ error: 'Invalid Jira key' });
}
try {
const result = await jiraService.customerCancelled(key, req.body || {});
res.json({ success: true, ...result });
} catch (err) {
logger.error('customerCancelled route failed', { key, error: err.message, details: err.details });
res.status(err.status && err.status < 500 ? err.status : 500).json({
success: false, error: err.message, details: err.details || null
});
}
});
// Body: { primaryKey!, comment?, subType?, component?, businessService?, system?, cause?, internal? }
router.post('/wxccai/ticket/:key/duplicate', async (req, res) => {
const key = req.params.key?.trim().toUpperCase();
if (!key || !KEY_RE.test(key)) {
return res.status(400).json({ error: 'Invalid Jira key' });
}
const body = req.body || {};
if (!body.primaryKey) {
return res.status(400).json({ error: 'primaryKey is required in the body' });
}
try {
const result = await jiraService.markDuplicate(key, {
...body,
primaryKey: String(body.primaryKey).trim().toUpperCase(),
});
res.json({ success: true, ...result });
} catch (err) {
logger.error('duplicate route failed', { key, error: err.message, details: err.details });
res.status(err.status && err.status < 500 ? err.status : 500).json({
success: false, error: err.message, details: err.details || null
});
}
});
// ========================
// Assets object cache admin
// GET /api/wxccai/admin/caches — status of every cache
// GET /api/wxccai/admin/caches/:name/status — status of one cache
// POST /api/wxccai/admin/caches/:name/refresh — force resync of one cache
// POST /api/wxccai/admin/caches/refreshAll — resync every cache in parallel
//
// Cache names: stores | businessServices | systems | causes.
//
// Also kept as backward-compat aliases:
// GET /api/wxccai/admin/storesCache/status -> caches/stores/status
// POST /api/wxccai/admin/storesCache/refresh -> caches/stores/refresh
//
// These are admin endpoints (not user-facing). Status is safe from a health
// check; refresh triggers a full AQL walk via the personal PAT and takes a
// few seconds. Refresh is a no-op if creds aren't configured or a sync for
// that cache is already in progress.
// ========================
import { allCaches, cachesByName } from '../services/jira/caches.js';
router.get('/wxccai/admin/caches', (req, res) => {
try {
res.json({ caches: allCaches.map(c => c.status()) });
} catch (err) {
logger.error('caches status failed', { error: err.message });
res.status(500).json({ error: err.message });
}
});
router.get('/wxccai/admin/caches/:name/status', (req, res) => {
const c = cachesByName[req.params.name];
if (!c) return res.status(404).json({ error: `unknown cache '${req.params.name}'`, available: Object.keys(cachesByName) });
try {
res.json(c.status());
} catch (err) {
logger.error('cache status failed', { name: req.params.name, error: err.message });
res.status(500).json({ error: err.message });
}
});
router.post('/wxccai/admin/caches/:name/refresh', async (req, res) => {
const c = cachesByName[req.params.name];
if (!c) return res.status(404).json({ error: `unknown cache '${req.params.name}'`, available: Object.keys(cachesByName) });
try {
const result = await c.refresh({ force: true });
res.json({ success: true, ...result, status: c.status() });
} catch (err) {
logger.error('cache refresh failed', { name: req.params.name, error: err.message });
res.status(500).json({
success: false,
error: err.message,
status: c.status(),
});
}
});
router.post('/wxccai/admin/caches/refreshAll', async (req, res) => {
const results = await Promise.allSettled(allCaches.map(c => c.refresh({ force: true })));
res.json({
results: results.map((r, i) => ({
cache: allCaches[i].name,
ok: r.status === 'fulfilled',
...(r.status === 'fulfilled' ? r.value : { error: r.reason?.message }),
})),
statuses: allCaches.map(c => c.status()),
});
});
// Backward-compat aliases (old storesCache path)
router.get('/wxccai/admin/storesCache/status', (req, res) => {
try {
res.json(cachesByName.stores.status());
} catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/wxccai/admin/storesCache/refresh', async (req, res) => {
try {
const result = await cachesByName.stores.refresh({ force: true });
res.json({ success: true, ...result, status: cachesByName.stores.status() });
} catch (err) {
res.status(500).json({ success: false, error: err.message, status: cachesByName.stores.status() });
}
});
// ======================== // ========================
// Assets diagnostic (non-production only) // Assets diagnostic (non-production only)
// GET /api/wxccai/debug/assetsProbe?storeNumber=305 // GET /api/wxccai/debug/assetsProbe?storeNumber=305

View file

@ -1,732 +0,0 @@
// 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

@ -1,309 +0,0 @@
// Factory for building in-memory + on-disk caches of Jira Assets objects.
//
// All caches share the same shape:
// - In-memory Map<normalizedKey, { objectId, objectKey, label, syncedAt }>
// - On-disk JSON at `${config.caches.dir}/${name}.json` (atomic write)
// - Populated via the personal-PAT `assetsSyncClient` (never the service
// account, so the same permission workaround for issue #1 applies)
//
// Callers get back an object with the same API as the old storesCache module:
// { name, displayName, get, status, refresh, init, shutdown }
//
// Concrete instances live in stores/businessServices/systems/causes cache
// modules and just call `createAssetsObjectCache({...})` with the right
// objectTypeId + key extraction function. See docs on those modules for the
// tenant-specific object types.
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';
/**
* Build a new Assets object cache instance.
*
* @param {Object} opts
* @param {string} opts.name Short slug: 'stores' | 'businessServices' | 'systems' | 'causes'.
* Used for filename, log tags, admin route param.
* @param {string} opts.displayName Human-readable name for log messages ('Stores', 'Business Services').
* @param {string} opts.objectTypeId Assets object type to enumerate (e.g. '109').
* @param {Function} opts.keyFromObject (obj) => string | null. Derives the canonical cache key
* from an Assets object entry. Return null to skip the object
* (it'll count as "orphaned" in the refresh stats).
* @param {Function} opts.normalizeKey (userInput) => string | null. Normalizes a caller-supplied
* key for lookup. Must produce the same output as keyFromObject
* for equivalent inputs.
* @param {string} [opts.filePath] Override the on-disk cache path. Defaults to
* `${config.caches.dir}/${name}.json`.
* @returns {Object} cache instance
*/
export function createAssetsObjectCache({
name,
displayName,
objectTypeId,
keyFromObject,
normalizeKey,
filePath,
}) {
if (!name || !objectTypeId || typeof keyFromObject !== 'function' || typeof normalizeKey !== 'function') {
throw new Error(`createAssetsObjectCache: name, objectTypeId, keyFromObject, normalizeKey are required (got name=${name})`);
}
const displayNameFinal = displayName || name;
const cachePath = () => filePath || path.join(config.caches.dir, `${name}.json`);
const tag = `[cache:${name}]`;
const state = {
cache: new Map(),
lastSyncAt: null,
lastError: null,
syncing: false,
scheduleTimer: null,
loadedFromDisk: false,
};
function get(userKey) {
const k = normalizeKey(userKey);
if (!k) return null;
return state.cache.get(k) || null;
}
function status() {
const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null;
return {
name,
displayName: displayNameFinal,
objectTypeId,
count: 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: cachePath(),
refreshIntervalHours: config.caches.refreshIntervalHours,
staleAfterHours: config.caches.staleAfterHours,
};
}
async function loadFromDisk() {
const p = cachePath();
try {
const raw = await fs.readFile(p, '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(`${tag} loaded from disk`, {
path: p,
count: state.cache.size,
lastSyncAt: state.lastSyncAt,
});
return true;
}
logger.warn(`${tag} disk file present but shape unexpected; ignoring`, { path: p });
} catch (e) {
if (e.code === 'ENOENT') {
logger.info(`${tag} no on-disk snapshot yet; will populate on first sync`, { path: p });
} else {
logger.warn(`${tag} failed to load from disk`, { path: p, error: e.message });
}
}
return false;
}
async function saveToDisk() {
const p = cachePath();
const dir = path.dirname(p);
try {
await fs.mkdir(dir, { recursive: true });
const payload = {
version: 1,
cacheName: name,
objectTypeId,
lastSyncAt: state.lastSyncAt,
count: state.cache.size,
entries: Object.fromEntries(state.cache),
};
const tmp = `${p}.tmp`;
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
await fs.rename(tmp, p);
logger.debug(`${tag} persisted to disk`, { path: p, count: state.cache.size });
} catch (e) {
logger.warn(`${tag} failed to persist to disk`, { path: p, error: e.message });
}
}
/**
* Full paginated resync via personal PAT. Rebuilds the in-memory Map
* atomically (only swaps once every page succeeded). Never reentrant.
*/
async function refresh({ force = false } = {}) {
if (state.syncing) {
logger.info(`${tag} 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 pageSize = config.caches.pageSize || 500;
const maxPages = config.caches.maxPages || 200;
try {
const newCache = new Map();
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(
`${tag} 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 key = keyFromObject(obj);
if (!key || !obj?.id) {
orphaned++;
continue;
}
newCache.set(key, {
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(`${tag} 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(`${tag} sync hit page cap; some objects may be missing`, {
maxPages,
pageSize,
seenTotal,
});
}
state.cache = newCache;
state.lastSyncAt = new Date().toISOString();
await saveToDisk();
const durationMs = Date.now() - started;
logger.info(`${tag} refresh complete`, {
count: state.cache.size,
orphaned,
pagesRead: page,
durationMs,
forced: force,
});
return {
ok: true,
cache: name,
count: state.cache.size,
orphaned,
pagesRead: page,
durationMs,
};
} catch (e) {
state.lastError = { message: e.message, at: new Date().toISOString() };
logger.error(`${tag} refresh failed`, { error: e.message });
throw e;
} finally {
state.syncing = false;
}
}
/**
* Load the disk snapshot, kick off a background refresh if
* missing/stale, schedule periodic refresh. Idempotent.
*/
async function init() {
if (!config.caches.enabled) {
logger.info(`${tag} disabled via config.caches.enabled; skipping init`);
return;
}
await loadFromDisk();
const staleAfterMs = (config.caches.staleAfterHours || 48) * 3600 * 1000;
const shouldRefreshNow = !state.lastSyncAt
|| (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs;
if (shouldRefreshNow && isAssetsSyncConfigured()) {
logger.info(`${tag} missing or stale; starting background refresh`);
refresh().catch(e => logger.error(`${tag} initial refresh failed`, { error: e.message }));
} else if (shouldRefreshNow) {
logger.warn(`${tag} missing or stale but assets sync not configured; skipping initial refresh`);
}
const intervalHours = config.caches.refreshIntervalHours;
if (intervalHours > 0) {
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
state.scheduleTimer = setInterval(() => {
if (!isAssetsSyncConfigured()) return;
refresh().catch(e => logger.error(`${tag} scheduled refresh failed`, { error: e.message }));
}, intervalHours * 3600 * 1000);
state.scheduleTimer.unref?.();
logger.info(`${tag} periodic refresh scheduled`, { intervalHours });
}
}
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;
}
return { name, displayName: displayNameFinal, objectTypeId, get, status, refresh, init, shutdown };
}

View file

@ -1,150 +0,0 @@
// 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

@ -1,154 +0,0 @@
// 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

@ -1,32 +0,0 @@
// Business Service cache — Assets ObjectType 100 in schema 68.
//
// Backs the `customfield_10224 Business Service` validator that fires on the
// Resolved transition of SS tickets. Populated via the personal PAT sync.
//
// Examples of real values observed on closed SS tickets: "Store Technology".
// The set is expected to be small (dozens, not thousands) so pagination is
// usually one page.
import { createAssetsObjectCache } from './assetsObjectCache.js';
function normalizeName(raw) {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase();
return s || null;
}
function keyFromObject(obj) {
const label = obj?.label || obj?.name;
if (!label) return null;
return String(label).trim().toLowerCase();
}
const instance = createAssetsObjectCache({
name: 'businessServices',
displayName: 'Business Services',
objectTypeId: '100',
keyFromObject,
normalizeKey: normalizeName,
});
export const { get, status, refresh, init, shutdown } = instance;
export default instance;

View file

@ -1,23 +0,0 @@
// Barrel export for all Assets object cache instances.
//
// Every cache is a call to createAssetsObjectCache(...) with tenant-specific
// object types and key extractors. Consumers use one of:
// - the named module import (storesCache, businessServicesCache, ...) for
// direct .get() lookups on the hot path
// - `allCaches` for lifecycle operations (init on boot, iterate for admin)
// - `cachesByName` for admin routes that take a cache name as a param
import storesCache from './storesCache.js';
import businessServicesCache from './businessServicesCache.js';
import systemsCache from './systemsCache.js';
import causesCache from './causesCache.js';
export { storesCache, businessServicesCache, systemsCache, causesCache };
export const allCaches = [
storesCache,
businessServicesCache,
systemsCache,
causesCache,
];
export const cachesByName = Object.fromEntries(allCaches.map(c => [c.name, c]));

View file

@ -1,32 +0,0 @@
// Causes cache — Assets ObjectType 107 "Cause Code" in schema 68.
//
// Backs the `customfield_10233 Cause` validator that fires on the Resolved
// transition of SS tickets. Populated via the personal PAT sync.
//
// Real closed tickets consistently show "Unknown" (objectId 83132) here,
// which is the workflow's designed catch-all value for cases where the root
// cause can't be pinned down. Perfect fallback for AI-driven closures.
import { createAssetsObjectCache } from './assetsObjectCache.js';
function normalizeName(raw) {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase();
return s || null;
}
function keyFromObject(obj) {
const label = obj?.label || obj?.name;
if (!label) return null;
return String(label).trim().toLowerCase();
}
const instance = createAssetsObjectCache({
name: 'causes',
displayName: 'Causes',
objectTypeId: '107',
keyFromObject,
normalizeKey: normalizeName,
});
export const { get, status, refresh, init, shutdown } = instance;
export default instance;

View file

@ -1,84 +0,0 @@
// 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

@ -1,163 +0,0 @@
// Convenience wrappers around closeTicket() aimed at Contact Center agent
// intents. Each one calls closeTicket with a specific resolution + a
// standardized audit-trail comment, plus any intent-specific side effects
// (like creating a formal "Duplicate" issueLink).
//
// These are thin — the real work lives in issues.js closeTicket +
// setSSValidatorFields. If a caller wants full control, they can still hit
// /ticket/:key/close directly.
import logger from '../../utilities/logger.js';
import { jiraClient } from './client.js';
import { closeTicket } from './issues.js';
const KEY_RE = /^[A-Z]+-\d+$/;
/**
* "The caller confirms it's fixed." Resolves the ticket as Done.
*
* @param {string} key
* @param {Object} [opts]
* @param {string} [opts.comment] Extra context to append; a default
* is used if omitted.
* @param {string} [opts.subType] Feeds the validator-field default
* lookup in ssCloseDefaults.js.
* @param {string} [opts.component] Override the default component.
* @param {string} [opts.businessService]
* @param {string} [opts.system]
* @param {string} [opts.cause] Defaults to "Unknown" for CC-driven
* closes, which is the tenant's designed
* catch-all Cause Code.
* @param {boolean} [opts.internal=true] Post comment as internal (visible only
* to Service Desk Team). Default true
* because CC-agent notes should not
* show to the customer as public replies.
*/
export async function confirmFixed(key, opts = {}) {
if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`);
const {
comment,
subType,
component, businessService, system, cause,
internal = true,
} = opts;
const finalComment = comment
|| 'Closed via WxCC AI agent: caller confirmed the issue is resolved.';
return closeTicket(key, {
resolution: 'Done',
comment: finalComment,
internal,
component, businessService, system, cause,
subType,
});
}
/**
* "The caller wants to cancel their ticket." Resolves as Won't Do.
*
* @param {string} key
* @param {Object} [opts]
* @param {string} [opts.reason] Freeform reason to include in the
* comment (e.g. "customer says the
* issue self-resolved").
* @param {string} [opts.subType] Feeds default lookup.
* @param {string} [opts.component]
* @param {string} [opts.businessService]
* @param {string} [opts.system]
* @param {string} [opts.cause] Defaults to whatever the subType map
* says (usually "Unknown").
* @param {boolean} [opts.internal=true]
*/
export async function customerCancelled(key, opts = {}) {
if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`);
const {
reason,
subType,
component, businessService, system, cause,
internal = true,
} = opts;
const comment = reason
? `Closed via WxCC AI agent: caller requested cancellation. Reason: ${reason}`
: 'Closed via WxCC AI agent: caller requested cancellation.';
return closeTicket(key, {
resolution: "Won't Do",
comment,
internal,
component, businessService, system, cause,
subType,
});
}
/**
* "This ticket is a duplicate of SS-XXXX."
*
* Closes the given ticket with resolution=Duplicate AND creates a formal
* `Duplicate` issue link pointing to the primary ticket. Both operations
* are attempted; if the link creation fails, the close still completes
* (the link error is logged and surfaced in the response).
*
* @param {string} key
* @param {Object} opts
* @param {string} opts.primaryKey The ticket this is a duplicate OF (required).
* @param {string} [opts.comment] Extra context; a default is used if omitted.
* @param {string} [opts.subType]
* @param {string} [opts.component]
* @param {string} [opts.businessService]
* @param {string} [opts.system]
* @param {string} [opts.cause]
* @param {boolean} [opts.internal=true]
*/
export async function markDuplicate(key, opts = {}) {
if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`);
const {
primaryKey,
comment,
subType,
component, businessService, system, cause,
internal = true,
} = opts;
if (!primaryKey || !KEY_RE.test(primaryKey)) {
throw new Error(`markDuplicate requires primaryKey; got "${primaryKey}"`);
}
if (primaryKey === key) {
throw new Error(`markDuplicate: primaryKey (${primaryKey}) cannot equal the ticket being closed (${key})`);
}
const finalComment = comment
|| `Closed via WxCC AI agent: duplicate of ${primaryKey}.`;
// Try to create the formal Duplicate link first. If Jira rejects it (e.g.
// primary doesn't exist), abort — closing without the link would lose
// the connection.
let linkResult = null;
try {
await jiraClient.post('/rest/api/3/issueLink', {
type: { name: 'Duplicate' },
inwardIssue: { key: key },
outwardIssue: { key: primaryKey },
}, { headers: { 'Content-Type': 'application/json' } });
linkResult = { ok: true, primaryKey, linkType: 'Duplicate' };
logger.info('markDuplicate: created formal issueLink', { key, primaryKey });
} catch (err) {
const detail = err.response?.data?.errorMessages?.join('; ') || err.message;
logger.warn('markDuplicate: issueLink create failed; will still close', {
key, primaryKey, error: detail, status: err.response?.status,
});
linkResult = { ok: false, primaryKey, error: detail };
// We continue: the close-with-comment still records the intent.
}
const closeResult = await closeTicket(key, {
resolution: 'Duplicate',
comment: finalComment,
internal,
component, businessService, system, cause,
subType,
});
return { ...closeResult, issueLink: linkResult };
}

View file

@ -1,121 +0,0 @@
// 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 });
}

View file

@ -1,635 +0,0 @@
// 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';
import { businessServicesCache, systemsCache, causesCache, storesCache } from './caches.js';
import { getDefaultsForSubType } from '../../config/ssCloseDefaults.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}`);
}
}
/**
* Search open SS tickets for a given store. Useful for store-based callers
* (associates who don't have their own reporter email the store shares
* accounts). Enriches each result with plain-text description + last 6
* public comments, same shape as searchOpenTicketsByReporterEmail so the
* downstream Grok summarizer works on either result set.
*
* We probed the tenant's JQL behavior for the CMDB "Store Number" field:
* only the padded human-readable form works (`"Store Number" = "00782"`).
* Neither the Assets objectId, the ASSET-<id> objectKey, the workspace-
* qualified id, nor the raw digit form matches the field resolves
* against the object's *label*. So we normalize the caller's storeNumber
* to 5 digits and search by that string.
*
* The storesCache is consulted first to fail-fast with a clear error when
* the store doesn't exist (better UX than "no tickets found for a
* nonexistent store").
*/
export async function searchOpenTicketsByStoreNumber(rawStoreNumber) {
if (rawStoreNumber === null || rawStoreNumber === undefined || String(rawStoreNumber).trim() === '') {
throw new Error('storeNumber is required');
}
const s = String(rawStoreNumber).trim();
if (!/^\d+$/.test(s)) {
throw new Error(`Invalid storeNumber "${rawStoreNumber}"; must be numeric`);
}
const padded = s.padStart(5, '0');
// Validate against the cache — nicer error than an empty result set for
// a typo'd store number. Miss doesn't necessarily mean invalid (the
// cache may not have been synced yet), so we only warn, not error.
const cachedStore = storesCache.get(padded);
if (!cachedStore) {
logger.warn('searchOpenTicketsByStoreNumber: store not in cache; proceeding anyway', {
storeNumber: padded,
cacheStatus: storesCache.status(),
});
}
const jql = `project = SS
AND "Store Number" = "${padded}"
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", "reporter", "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 store search failed:', error.response?.data || error.message);
throw new Error(`Failed to search tickets for store ${padded}: ${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;
}
}
/**
* Build the Cloud CMDB request-field value shape for a single object:
* [{ id: "<workspaceId>:<objectId>" }]
* Matches buildStoreFieldRef in assets.js. See that function's comment for
* why this is needed and how DC/Server used a different shape.
*/
function cmdbFieldRef(objectId) {
const workspaceId = config.jira.assetsWorkspaceId;
if (!workspaceId) {
logger.error('cmdbFieldRef: JIRA_ASSETS_WORKSPACE_ID missing; CMDB field writes will silently fail on Jira Cloud');
return [{ objectId: String(objectId) }];
}
return [{ id: `${workspaceId}:${objectId}` }];
}
/**
* Resolve a caller-supplied CMDB field value.
* - If the value looks like an Assets objectId (all digits), use directly.
* - Otherwise look it up in the given cache by name.
* Returns objectId or null if not resolvable.
*/
function resolveCmdbNameOrId(nameOrId, cache) {
if (nameOrId === null || nameOrId === undefined) return null;
const s = String(nameOrId).trim();
if (!s) return null;
if (/^\d+$/.test(s)) return s; // already an objectId
const entry = cache.get(s);
return entry?.objectId || null;
}
/**
* Set the four Resolved-transition workflow validator fields on a ticket
* before actually calling the transition. This is a JSM workflow post-function
* quirk: the fields aren't on the transition screen but the validator fires
* on execution if they're empty.
*
* Resolution order per field:
* 1. Explicit caller value (name or objectId for CMDB fields)
* 2. Whatever's already on the ticket (only if preserveExisting=true)
* 3. Per-subType default from src/config/ssCloseDefaults.js
*
* Never overwrites a field that step 2 preserved. If a caller value doesn't
* resolve (name not in cache), throws with a clear error naming the field.
*
* @param {string} key Jira issue key
* @param {Object} opts
* @param {string} [opts.component] Jira Component name
* @param {string} [opts.businessService] Business Service name or objectId
* @param {string} [opts.system] System name or objectId
* @param {string} [opts.cause] Cause name or objectId
* @param {string} [opts.subType] SS request subType, used to look up defaults
* @param {boolean} [opts.preserveExisting=true]
* @returns {Promise<Object>} { key, applied: {fieldId: value}, skipped: {fieldId: reason} }
*/
export async function setSSValidatorFields(key, opts = {}) {
if (!key || !KEY_RE.test(key)) throw new Error(`Invalid ticket key: "${key}"`);
const {
component,
businessService,
system,
cause,
subType,
preserveExisting = true,
} = opts;
// Snapshot the current values so we can respect existing triage.
let existing = { component: null, businessService: null, system: null, cause: null };
try {
const { data } = await jiraClient.get(
`/rest/api/3/issue/${key}?fields=components,customfield_10224,customfield_10225,customfield_10233`
);
const f = data.fields || {};
existing = {
component: f.components?.[0]?.name || null,
businessService: f.customfield_10224?.[0]?.objectId || null,
system: f.customfield_10225?.[0]?.objectId || null,
cause: f.customfield_10233?.[0]?.objectId || null,
};
} catch (e) {
logger.warn('setSSValidatorFields: failed to fetch current ticket state; will apply all fields without preserve-existing check', {
key, error: e.message,
});
}
const defaults = getDefaultsForSubType(subType);
const fields = {};
const applied = {};
const skipped = {};
// ---- Component (Jira native, name-based) ----
if (component) {
fields.components = [{ name: String(component) }];
applied.components = component;
} else if (preserveExisting && existing.component) {
skipped.components = `preserved existing '${existing.component}'`;
} else if (defaults.component) {
fields.components = [{ name: defaults.component }];
applied.components = `${defaults.component} (default${subType ? ` for '${subType}'` : ''})`;
} else {
throw new Error(`No component provided, none on ticket, and no default for subType='${subType || ''}'`);
}
// ---- Business Service (CMDB customfield_10224) ----
const bsPlan = resolveOneCmdbField({
fieldName: 'Business Service',
callerValue: businessService,
existingObjectId: existing.businessService,
defaultValue: defaults.businessService,
subType,
cache: businessServicesCache,
preserveExisting,
});
if (bsPlan.objectId) {
fields.customfield_10224 = cmdbFieldRef(bsPlan.objectId);
applied.customfield_10224 = bsPlan.applied;
} else {
skipped.customfield_10224 = bsPlan.skipped;
}
// ---- System (CMDB customfield_10225) ----
const sysPlan = resolveOneCmdbField({
fieldName: 'System',
callerValue: system,
existingObjectId: existing.system,
defaultValue: defaults.system,
subType,
cache: systemsCache,
preserveExisting,
});
if (sysPlan.objectId) {
fields.customfield_10225 = cmdbFieldRef(sysPlan.objectId);
applied.customfield_10225 = sysPlan.applied;
} else {
skipped.customfield_10225 = sysPlan.skipped;
}
// ---- Cause (CMDB customfield_10233) ----
const causePlan = resolveOneCmdbField({
fieldName: 'Cause',
callerValue: cause,
existingObjectId: existing.cause,
defaultValue: defaults.cause,
subType,
cache: causesCache,
preserveExisting,
});
if (causePlan.objectId) {
fields.customfield_10233 = cmdbFieldRef(causePlan.objectId);
applied.customfield_10233 = causePlan.applied;
} else {
skipped.customfield_10233 = causePlan.skipped;
}
if (Object.keys(fields).length === 0) {
logger.info('setSSValidatorFields: nothing to write (all fields preserved)', { key, existing });
return { key, applied, skipped };
}
try {
await jiraClient.put(`/rest/api/3/issue/${key}`, { fields }, {
headers: { 'Content-Type': 'application/json' }
});
logger.info('setSSValidatorFields: applied', { key, applied, skipped });
return { key, applied, skipped };
} catch (err) {
logger.error('setSSValidatorFields put 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;
}
}
function resolveOneCmdbField({
fieldName, callerValue, existingObjectId, defaultValue, subType, cache, preserveExisting,
}) {
if (callerValue) {
const objectId = resolveCmdbNameOrId(callerValue, cache);
if (!objectId) {
const cacheStatus = cache.status();
const err = new Error(
`${fieldName} value '${callerValue}' not found in ${cache.name} cache ` +
`(${cacheStatus.count} entries, last synced ${cacheStatus.lastSyncAt || 'never'}). ` +
`Try POST /admin/caches/${cache.name}/refresh, or supply an objectId directly.`
);
err.status = 400;
throw err;
}
return { objectId, applied: `${callerValue} (objectId ${objectId})` };
}
if (preserveExisting && existingObjectId) {
return { objectId: null, skipped: `preserved existing objectId ${existingObjectId}` };
}
if (defaultValue) {
const objectId = resolveCmdbNameOrId(defaultValue, cache);
if (!objectId) {
throw new Error(
`Default ${fieldName} '${defaultValue}' (for subType '${subType || 'none'}') ` +
`not found in ${cache.name} cache. This is a config bug in ssCloseDefaults.js.`
);
}
return { objectId, applied: `${defaultValue} (default${subType ? ` for '${subType}'` : ''}, objectId ${objectId})` };
}
throw new Error(`No ${fieldName} provided, none on ticket, and no default for subType='${subType || ''}'`);
}
/**
* Convenience: find a "closing" transition and execute it. When the target
* transition is done-category, first populates the 4 workflow validator
* fields (components + 3 CMDB customfields) via setSSValidatorFields. If a
* non-done transition is chosen, does NOT send a resolution (fixes Forgejo
* bug #9 where the old code always sent 'Done' and errored on transitions
* whose screen doesn't include the Resolution field).
*
* Backward-compatible with the old signature; new fields are opt-in.
*/
export async function closeTicket(key, opts = {}) {
const {
transitionName,
resolution,
comment,
internal = false,
// New (opt-in) fields for the SS validator workflow
component,
businessService,
system,
cause,
subType,
preserveExistingClassification = true,
// Escape hatch: skip the validator-field write. Useful if the caller
// has already set the fields via a separate PATCH.
skipValidatorFields = false,
} = opts;
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(', ')}`);
}
}
const isDone = chosen.to?.statusCategory === 'done';
let validatorFieldsResult = null;
// Only satisfy the SS validator when we're actually closing.
if (isDone && !skipValidatorFields) {
validatorFieldsResult = await setSSValidatorFields(key, {
component, businessService, system, cause, subType,
preserveExisting: preserveExistingClassification,
});
}
// Fix #9: don't send `resolution` unless we're actually transitioning to
// a done-category status. On non-done transitions, `resolution` isn't on
// the transition screen and Jira 400s.
const resolutionToSend = isDone ? (resolution || 'Done') : null;
const txResult = await transitionTicket(key, chosen.id, {
resolution: resolutionToSend,
comment,
internal,
});
return {
...txResult,
transitionUsed: chosen.name,
newStatusCategory: chosen.to?.statusCategory || null,
validatorFields: validatorFieldsResult,
};
}

View file

@ -1,149 +0,0 @@
// 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

@ -1,53 +0,0 @@
// Stores cache — instance of the shared Assets object cache factory.
//
// Store objects live in ObjectType 109 (Store Address / Hierarchy) in schema
// 68. The `label` on those objects is the 5-digit padded store number
// (verified on real objects in the tenant). We also fall back to the "Store
// Number" attribute if the label isn't a plain number.
//
// Primary caller: resolveStoreAssetReference in assets.js. Populated by the
// personal-PAT sync path because the service account is filtered out of type
// 109 (see Forgejo issue #1).
import config from '../../config/index.js';
import { createAssetsObjectCache } from './assetsObjectCache.js';
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) {
const storeNumberAttrName = config.jira.assetsStoreNumberAttribute || 'Store Number';
const label = obj?.label || obj?.name;
if (label && /^\d+$/.test(String(label).trim())) {
return String(label).trim().padStart(5, '0');
}
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;
}
const instance = createAssetsObjectCache({
name: 'stores',
displayName: 'Stores',
objectTypeId: config.jira.assetsStoreObjectTypeId || '109',
keyFromObject: extractStoreNumberFromObject,
normalizeKey: normalizeStoreNumber,
});
export const { get, status, refresh, init, shutdown } = instance;
export default instance;

View file

@ -1,30 +0,0 @@
// Systems cache — Assets ObjectType 103 in schema 68.
//
// Backs the `customfield_10225 System` validator that fires on the Resolved
// transition of SS tickets. Populated via the personal PAT sync.
//
// Examples of real values observed on closed SS tickets: "UKG Pro WFM".
import { createAssetsObjectCache } from './assetsObjectCache.js';
function normalizeName(raw) {
if (raw === null || raw === undefined) return null;
const s = String(raw).trim().toLowerCase();
return s || null;
}
function keyFromObject(obj) {
const label = obj?.label || obj?.name;
if (!label) return null;
return String(label).trim().toLowerCase();
}
const instance = createAssetsObjectCache({
name: 'systems',
displayName: 'Systems',
objectTypeId: '103',
keyFromObject,
normalizeKey: normalizeName,
});
export const { get, status, refresh, init, shutdown } = instance;
export default instance;

File diff suppressed because it is too large Load diff