feat(close): programmatic SS-ticket closure for the CC agent

Extends the app so a Contact Center AI agent can close SS tickets
end-to-end (customer-confirmed fix, cancellation, duplicate) without a
human touch — the natural next step after ticket creation landed.

The SS Resolved (161) workflow validator requires four fields on the
ticket at execution time (components + 3 CMDB customfields for Business
Service / System / Cause). The transition screen itself only exposes
resolution, so the closer has to PUT these into the ticket first and
*then* fire the transition. This change wires up the whole flow with
sensible per-subType defaults, so the AI can close a typical ticket
with just { subType, comment }.

Design (see Forgejo #10 for the full write-up):

- Generalized services/jira/storesCache into a reusable factory
  createAssetsObjectCache. Same in-memory Map + on-disk JSON snapshot +
  startAt/maxResults pagination + personal-PAT auth. Store cache becomes
  one instance; three new caches join it for the CMDB validator fields
  (businessServices=100, systems=103, causes=107 — all in schema 68, so
  the same permission workaround from Forgejo #8 keeps working).

- Boot-time init warms all four caches in parallel; a new
  services/jira/caches.js barrel exposes them for lifecycle (init) and
  by-name lookup (admin routes). Full sync takes ~5s in the tenant
  (2661 stores dominate — the three CMDB caches together hold ~653
  entries and finish in ~1s).

- Admin surface rebuilt around a plural /admin/caches path:
    GET  /admin/caches                    — status of all four
    GET  /admin/caches/:name/status       — one cache
    POST /admin/caches/:name/refresh      — force resync of one
    POST /admin/caches/refreshAll         — parallel refresh
  Old /admin/storesCache/{status,refresh} are kept as aliases.

- Config renamed: config.storesCache -> config.caches (dir instead of
  path; filenames auto-derived per cache). Env vars renamed to
  CACHES_* (dir/refresh-hours/stale-hours/page-size/max-pages).
  .env.example updated.

- ssCloseDefaults.js maps every supported SS subType to a
  {component, businessService, system, cause} tuple. Values verified
  against the live caches so they resolve at runtime. Falls back to a
  safe __default__ tuple (Help Desk / Store Technology / "I can't find
  my option - Misc" / Unknown) for un-mapped subTypes — those catch-all
  values are the tenant's designed "I don't know" escape valves.

- issues.js closeTicket completely reworked:
    * setSSValidatorFields helper: reads current ticket state, resolves
      each of the four fields via caller > existing-on-ticket > default,
      PUTs them using the Cloud CMDB shape [{id: "<ws>:<objectId>"}]
      (same fix as Forgejo #8 for Store Number).
    * Only writes fields that need writing; preserves human triage by
      default (preserveExistingClassification=true).
    * Fixes Forgejo #9: only sends the resolution field when the
      chosen transition targets statusCategory=done. Non-done
      transitions (e.g. "Waiting for customer") no longer 400 on
      "Field 'resolution' cannot be set."

- closeHelpers.js adds three CC-agent intent wrappers:
    confirmFixed(key, {subType, ...})       → resolution=Done
    customerCancelled(key, {subType, ...})  → resolution=Won't Do
    markDuplicate(key, {primaryKey, ...})   → resolution=Duplicate +
                                              formal Duplicate issueLink
                                              to the primary
  Each posts a standardized internal audit-trail comment ("Closed via
  WxCC AI agent: …") that documents the automated action for the
  humans who inherit the ticket.

- New routes: POST /ticket/:key/{confirmFixed,customerCancelled,
  duplicate} plus an expanded body on the existing /close route.

Verified end-to-end: POST /ticket/SS-20948/confirmFixed with just
subType=Report a Technology issue transitioned the ticket to Resolved
with all four validator fields populated from defaults (Help Desk /
Store Technology / I can't find my option - Misc / Unknown), plus
resolution=Done. Direct Jira REST GET confirms every field persisted.

Closes Forgejo #9. Refs Forgejo #10.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-07 11:20:41 -04:00
parent b4bc6462a3
commit 034e657fcb
15 changed files with 1307 additions and 359 deletions

View file

@ -65,14 +65,16 @@ JIRA_STORE_CUSTOM_FIELD_ID=customfield_10261
ASSETS_SYNC_EMAIL=you@ae.com ASSETS_SYNC_EMAIL=you@ae.com
ASSETS_SYNC_TOKEN=REPLACE_ME ASSETS_SYNC_TOKEN=REPLACE_ME
# Where the local cache lives on disk (JSON). Gitignored. Regenerable via # Shared settings for all Assets object caches (stores, business services,
# POST /api/wxccai/admin/storesCache/refresh. # systems, causes). Each cache is a Map<key, entry> backed by a JSON file at
STORES_CACHE_PATH=./data/stores.json # ${CACHES_DIR}/${cacheName}.json. Regenerable via
# How often to run a full resync (hours). 0 disables the scheduler. # POST /api/wxccai/admin/caches/refreshAll (or refresh a single cache).
STORES_CACHE_REFRESH_HOURS=24 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 # Cache is considered "stale" after this many hours; boot-time refresh fires
# if the on-disk snapshot is older than this. # if the on-disk snapshot is older than this.
STORES_CACHE_STALE_AFTER_HOURS=48 CACHES_STALE_AFTER_HOURS=48
# --- xAI (Grok) --- # --- xAI (Grok) ---
XAI_API_KEY=REPLACE_ME XAI_API_KEY=REPLACE_ME

View file

@ -44,7 +44,10 @@ 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? }` (auto-picks the first "done" transition when `transitionName` omitted). - `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/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)
@ -57,16 +60,28 @@ Base path: `/api/wxccai`.
### Admin ### Admin
- `GET /admin/storesCache/status` — snapshot of the local Assets store cache: `{ storeCount, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }`. Safe for health checks. - `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.
- `POST /admin/storesCache/refresh` — force an immediate resync via the personal PAT (see below). Takes a few seconds and returns the new status. - `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`.
## Stores cache (Assets workaround) ## Assets object caches (Assets workaround)
`createSSRequest` needs to translate a store number into an Assets object id. The shared service account is silently filtered out of the Store object type, so the app maintains a local `storeNumber → objectId` cache that's populated from a **personal Atlassian PAT** (a real human account with the right Assets role). The service account is still used for everything else (creating tickets, comments, attachments). 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. 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.
@ -111,12 +126,56 @@ If `ASSETS_SYNC_TOKEN` is already in the process env (Setup A above), the wrappe
### Runtime behavior ### Runtime behavior
On boot the app loads the on-disk cache at `data/stores.json`, kicks off a background refresh if the snapshot is missing or older than `STORES_CACHE_STALE_AFTER_HOURS`, and schedules a periodic full resync every `STORES_CACHE_REFRESH_HOURS`. `resolveStoreAssetReference` then serves lookups from memory (sub-ms) with a live PAT lookup as fallback for brand-new stores. 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: Force a refresh at any time:
```bash ```bash
curl -X POST http://localhost:1866/api/wxccai/admin/storesCache/refresh 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

View file

@ -2,7 +2,7 @@ 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 * as storesCache from './services/jira/storesCache.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();
@ -135,9 +135,13 @@ 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 the stores cache: load from disk, schedule periodic refresh, // Kick off all Assets object caches: load from disk, schedule periodic
// and (if stale) start a background full refresh. Never blocks startup. // refresh, and (if stale) start a background full refresh. Never blocks
storesCache.init().catch(err => { // startup. Each cache runs independently — failure of one doesn't stop
logger.error('storesCache.init failed at boot', { error: err.message }); // 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

@ -86,18 +86,21 @@ export const config = {
assetsSyncToken: process.env.ASSETS_SYNC_TOKEN || null, assetsSyncToken: process.env.ASSETS_SYNC_TOKEN || null,
}, },
// Local cache of the Assets Store schema. Backed by a JSON file on disk; // Shared config for all Assets object caches (stores, business services,
// refreshed periodically via the personal PAT above. // systems, causes). Each cache is a `Map<key, entry>` backed by a JSON
storesCache: { // file on disk at `${dir}/${cacheName}.json`. All caches use the same
enabled: boolFromEnv('STORES_CACHE_ENABLED', true), // personal PAT auth path (see jira.assetsSyncToken above).
// Where the on-disk snapshot lives. Default is data/stores.json under caches: {
// the process cwd; gitignored. enabled: boolFromEnv('CACHES_ENABLED', true),
path: process.env.STORES_CACHE_PATH?.trim() // Directory where on-disk snapshots live. Filenames are auto-derived
|| path.resolve(process.cwd(), 'data', 'stores.json'), // per cache: stores.json, businessServices.json, systems.json,
refreshIntervalHours: intFromEnv('STORES_CACHE_REFRESH_HOURS', 24), // causes.json. Gitignored via data/ in .gitignore.
staleAfterHours: intFromEnv('STORES_CACHE_STALE_AFTER_HOURS', 48), dir: process.env.CACHES_DIR?.trim()
pageSize: intFromEnv('STORES_CACHE_PAGE_SIZE', 500), || path.resolve(process.cwd(), 'data'),
maxPages: intFromEnv('STORES_CACHE_MAX_PAGES', 200), 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

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

@ -288,20 +288,41 @@ router.get('/wxccai/ticket/:key/transitions', async (req, res) => {
} }
}); });
// Body: { transitionName?, resolution?, comment?, internal? } // Body: {
// If transitionName is omitted, we auto-pick the first "done" transition. // transitionName?, // explicit override; else auto-pick a "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 { transitionName, resolution, comment, internal } = req.body || {}; const body = req.body || {};
try { try {
const result = await jiraService.closeTicket(key, { const result = await jiraService.closeTicket(key, {
transitionName, transitionName: body.transitionName,
resolution: resolution || 'Done', // Deliberately NOT defaulting to 'Done' here anymore — closeTicket()
comment, // will only send a resolution when the chosen transition is
internal: !!internal // done-category (fixes bug #9).
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) {
@ -312,40 +333,152 @@ router.post('/wxccai/ticket/:key/close', async (req, res) => {
} }
}); });
// ======================== // Contact Center convenience wrappers over /close. Each takes a small body
// Stores cache admin // with just the intent-specific fields; the rest is defaulted from subType.
// GET /api/wxccai/admin/storesCache/status — snapshot of cache state
// POST /api/wxccai/admin/storesCache/refresh — force an immediate resync
// //
// These are admin endpoints (not user-facing). The status endpoint is safe to // Body: { comment?, subType?, component?, businessService?, system?, cause?, internal? }
// hit from a health check; the refresh endpoint triggers an AQL walk of the router.post('/wxccai/ticket/:key/confirmFixed', async (req, res) => {
// entire Store object type via the personal PAT and takes a few seconds on a const key = req.params.key?.trim().toUpperCase();
// warm connection. Refresh is a no-op if credentials aren't configured or a if (!key || !KEY_RE.test(key)) {
// sync is already in progress. return res.status(400).json({ error: 'Invalid Jira key' });
// ======================== }
router.get('/wxccai/admin/storesCache/status', (req, res) => {
try { try {
res.json(jiraService.storesCache.status()); const result = await jiraService.confirmFixed(key, req.body || {});
res.json({ success: true, ...result });
} catch (err) { } catch (err) {
logger.error('storesCache status failed', { error: err.message }); 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 }); res.status(500).json({ error: err.message });
} }
}); });
router.post('/wxccai/admin/storesCache/refresh', async (req, res) => { 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 { try {
const result = await jiraService.storesCache.refresh({ force: true }); res.json(c.status());
res.json({ success: true, ...result, status: jiraService.storesCache.status() });
} catch (err) { } catch (err) {
logger.error('storesCache refresh failed', { error: err.message }); 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({ res.status(500).json({
success: false, success: false,
error: err.message, error: err.message,
status: jiraService.storesCache.status(), 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

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

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

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

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

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

@ -5,6 +5,8 @@ import logger from '../../utilities/logger.js';
import config from '../../config/index.js'; import config from '../../config/index.js';
import { jiraClient, plainTextToAdf } from './client.js'; import { jiraClient, plainTextToAdf } from './client.js';
import { fetchPublicComments } from './comments.js'; import { fetchPublicComments } from './comments.js';
import { businessServicesCache, systemsCache, causesCache } from './caches.js';
import { getDefaultsForSubType } from '../../config/ssCloseDefaults.js';
const KEY_RE = /^[A-Z]+-\d+$/; const KEY_RE = /^[A-Z]+-\d+$/;
@ -261,12 +263,244 @@ export async function transitionTicket(key, transitionId, { resolution, comment,
} }
/** /**
* Convenience: find a "closing" transition and execute it. * Build the Cloud CMDB request-field value shape for a single object:
* Prefers explicit `transitionName` if provided, otherwise picks the first * [{ id: "<workspaceId>:<objectId>" }]
* transition whose target status is in category "done" (Jira's canonical * Matches buildStoreFieldRef in assets.js. See that function's comment for
* category for closed/resolved/completed), falling back to a name-based match. * why this is needed and how DC/Server used a different shape.
*/ */
export async function closeTicket(key, { transitionName, resolution = 'Done', comment, internal = false } = {}) { 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); const transitions = await getTransitions(key);
if (transitions.length === 0) { if (transitions.length === 0) {
throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`); throw new Error(`No workflow transitions available for ${key} (check assignee/permissions)`);
@ -286,5 +520,32 @@ export async function closeTicket(key, { transitionName, resolution = 'Done', co
} }
} }
return transitionTicket(key, chosen.id, { resolution, comment, internal }); 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,40 +1,15 @@
// In-memory + on-disk cache of `storeNumber → Assets objectId`. // Stores cache — instance of the shared Assets object cache factory.
// //
// Populated via assetsSyncClient (personal PAT). This is the primary lookup // Store objects live in ObjectType 109 (Store Address / Hierarchy) in schema
// path used by resolveStoreAssetReference — the raw service-account AQL call // 68. The `label` on those objects is the 5-digit padded store number
// is only a fallback for the (currently-blocked) scenario where the cache is // (verified on real objects in the tenant). We also fall back to the "Store
// unavailable and the service account has been granted enough permission to // Number" attribute if the label isn't a plain number.
// query the store type directly.
// //
// Storage: // Primary caller: resolveStoreAssetReference in assets.js. Populated by the
// In-memory: Map<paddedStoreNumber, entry> // personal-PAT sync path because the service account is filtered out of type
// On-disk: data/stores.json (see config.storesCache.path). Gitignored. // 109 (see Forgejo issue #1).
//
// Lifecycle:
// init() — called at boot; loads disk cache and kicks off
// background refresh if missing/stale, then schedules
// periodic refresh via setInterval.
// refresh({force}) — full paginated resync via personal PAT.
// get(storeNumber) — sync lookup, returns entry or null.
// status() — for the admin endpoint.
//
// Store objects live in ObjectType 109 in schema 68. The `label` on those
// objects is the padded store number (verified against real objects in the
// tenant); the `Store Number` attribute is a secondary source we fall back to.
import fs from 'node:fs/promises';
import path from 'node:path';
import config from '../../config/index.js'; import config from '../../config/index.js';
import logger from '../../utilities/logger.js'; import { createAssetsObjectCache } from './assetsObjectCache.js';
import { assetsAql, isAssetsSyncConfigured, describeSyncIdentity } from './assetsSyncClient.js';
const state = {
cache: new Map(),
lastSyncAt: null,
lastError: null,
syncing: false,
scheduleTimer: null,
loadedFromDisk: false,
};
function normalizeStoreNumber(raw) { function normalizeStoreNumber(raw) {
if (raw === null || raw === undefined) return null; if (raw === null || raw === undefined) return null;
@ -44,13 +19,12 @@ function normalizeStoreNumber(raw) {
return s.padStart(5, '0'); return s.padStart(5, '0');
} }
function extractStoreNumberFromObject(obj, storeNumberAttrName) { function extractStoreNumberFromObject(obj) {
// Primary: the label is the padded store number on type 109 objects. const storeNumberAttrName = config.jira.assetsStoreNumberAttribute || 'Store Number';
const label = obj?.label || obj?.name; const label = obj?.label || obj?.name;
if (label && /^\d+$/.test(String(label).trim())) { if (label && /^\d+$/.test(String(label).trim())) {
return String(label).trim().padStart(5, '0'); return String(label).trim().padStart(5, '0');
} }
// Secondary: check the named attribute.
const attrs = Array.isArray(obj?.attributes) ? obj.attributes : []; const attrs = Array.isArray(obj?.attributes) ? obj.attributes : [];
for (const attr of attrs) { for (const attr of attrs) {
const meta = attr?.objectTypeAttribute || attr?.typeAttribute || {}; const meta = attr?.objectTypeAttribute || attr?.typeAttribute || {};
@ -67,262 +41,13 @@ function extractStoreNumberFromObject(obj, storeNumberAttrName) {
return null; return null;
} }
/** const instance = createAssetsObjectCache({
* Sync (in-memory) lookup. Returns null on miss; never throws. name: 'stores',
*/ displayName: 'Stores',
export function get(storeNumber) { objectTypeId: config.jira.assetsStoreObjectTypeId || '109',
const n = normalizeStoreNumber(storeNumber); keyFromObject: extractStoreNumberFromObject,
if (!n) return null; normalizeKey: normalizeStoreNumber,
return state.cache.get(n) || null;
}
/**
* Snapshot for the admin status endpoint. Safe to serialize to JSON.
*/
export function status() {
const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null;
return {
storeCount: state.cache.size,
lastSyncAt: state.lastSyncAt,
ageSeconds: lastSyncEpoch ? Math.floor((Date.now() - lastSyncEpoch) / 1000) : null,
syncing: state.syncing,
lastError: state.lastError,
loadedFromDisk: state.loadedFromDisk,
assetsSyncConfigured: isAssetsSyncConfigured(),
syncIdentity: describeSyncIdentity(),
cachePath: config.storesCache.path,
refreshIntervalHours: config.storesCache.refreshIntervalHours,
staleAfterHours: config.storesCache.staleAfterHours,
};
}
async function loadFromDisk() {
const cachePath = config.storesCache.path;
try {
const raw = await fs.readFile(cachePath, 'utf8');
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && parsed.entries) {
state.cache = new Map(Object.entries(parsed.entries));
state.lastSyncAt = parsed.lastSyncAt || null;
state.loadedFromDisk = true;
logger.info('Loaded stores cache from disk', {
path: cachePath,
storeCount: state.cache.size,
lastSyncAt: state.lastSyncAt,
});
return true;
}
logger.warn('Stores cache file present but shape unexpected; ignoring', { path: cachePath });
} catch (e) {
if (e.code === 'ENOENT') {
logger.info('No stores cache on disk yet; will populate on first sync', { path: cachePath });
} else {
logger.warn('Failed to load stores cache from disk', { path: cachePath, error: e.message });
}
}
return false;
}
async function saveToDisk() {
const cachePath = config.storesCache.path;
const dir = path.dirname(cachePath);
try {
await fs.mkdir(dir, { recursive: true });
const payload = {
version: 1,
lastSyncAt: state.lastSyncAt,
storeCount: state.cache.size,
entries: Object.fromEntries(state.cache),
};
// Atomic-ish write: to tmp then rename, avoids readers seeing a half-written file.
const tmp = `${cachePath}.tmp`;
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
await fs.rename(tmp, cachePath);
logger.debug('Persisted stores cache to disk', { path: cachePath, storeCount: state.cache.size });
} catch (e) {
logger.warn('Failed to persist stores cache to disk', { path: cachePath, error: e.message });
}
}
/**
* Full paginated resync of all objects in the configured store object type
* via the personal PAT. Rebuilds the in-memory cache atomically (only swaps
* once every page has been read successfully) and then writes to disk.
*
* Never called reentrantly if a refresh is already in flight, returns
* { skipped: true, reason: 'already_syncing' }.
*/
export async function refresh({ force = false } = {}) {
if (state.syncing) {
logger.info('Stores cache refresh already in progress; ignoring duplicate call');
return { skipped: true, reason: 'already_syncing' };
}
if (!isAssetsSyncConfigured()) {
return { skipped: true, reason: 'not_configured' };
}
state.syncing = true;
state.lastError = null;
const started = Date.now();
const objectTypeId = config.jira.assetsStoreObjectTypeId || '109';
const storeNumberAttr = config.jira.assetsStoreNumberAttribute || 'Store Number';
const pageSize = config.storesCache.pageSize || 500;
const maxPages = config.storesCache.maxPages || 200; // safety cap; 200*500 = 100k stores
try {
const newCache = new Map();
// Atlassian Assets `/object/aql` paginates via startAt + maxResults +
// isLast. Some responses also cap `total` at 1000 as a hint, so we
// trust isLast (or an empty/short page) rather than the reported total.
let startAt = 0;
let page = 0;
let seenTotal = 0;
let expectedTotal = null;
let orphaned = 0;
while (page < maxPages) {
const r = await assetsAql(`objectTypeId = ${objectTypeId}`, {
startAt,
maxResults: pageSize,
includeAttributes: true,
}); });
if (r.status !== 200) { export const { get, status, refresh, init, shutdown } = instance;
const bodySnippet = typeof r.data === 'object' export default instance;
? JSON.stringify(r.data).slice(0, 400)
: String(r.data).slice(0, 400);
throw new Error(
`Assets AQL page ${page + 1} (startAt=${startAt}) returned HTTP ${r.status} (${r.statusText}). Body: ${bodySnippet}`
);
}
const values = Array.isArray(r.data?.values) ? r.data.values : [];
const returnedMaxResults = typeof r.data?.maxResults === 'number' ? r.data.maxResults : pageSize;
if (expectedTotal === null && typeof r.data?.total === 'number') {
expectedTotal = r.data.total;
}
for (const obj of values) {
const storeNumber = extractStoreNumberFromObject(obj, storeNumberAttr);
if (!storeNumber || !obj?.id) {
orphaned++;
continue;
}
newCache.set(storeNumber, {
objectId: String(obj.id),
objectKey: obj.objectKey || null,
label: obj.label || obj.name || null,
syncedAt: new Date().toISOString(),
});
}
seenTotal += values.length;
page++;
const isLast = r.data?.isLast === true
|| values.length === 0
|| values.length < returnedMaxResults;
logger.debug('Stores cache sync page', {
page,
startAt,
returnedMaxResults,
rowsThisPage: values.length,
seenTotal,
expectedTotal,
mappedSoFar: newCache.size,
orphanedSoFar: orphaned,
isLast,
});
if (isLast) break;
startAt += values.length;
}
if (page >= maxPages) {
logger.warn('Stores cache sync hit page cap; some stores may be missing', {
maxPages,
pageSize,
seenTotal,
});
}
// Only swap once we've read every page successfully.
state.cache = newCache;
state.lastSyncAt = new Date().toISOString();
await saveToDisk();
const durationMs = Date.now() - started;
logger.info('Stores cache refresh complete', {
storeCount: state.cache.size,
orphaned,
pagesRead: page,
durationMs,
forced: force,
});
return {
ok: true,
storeCount: state.cache.size,
orphaned,
pagesRead: page,
durationMs,
};
} catch (e) {
state.lastError = { message: e.message, at: new Date().toISOString() };
logger.error('Stores cache refresh failed', { error: e.message });
throw e;
} finally {
state.syncing = false;
}
}
/**
* Load the disk cache, kick off a background refresh if missing or stale, and
* schedule periodic refreshes. Idempotent safe to call more than once.
*
* Never throws. Individual failures are logged and surfaced through status().
*/
export async function init() {
if (!config.storesCache.enabled) {
logger.info('Stores cache disabled via config; skipping init');
return;
}
await loadFromDisk();
const staleAfterMs = (config.storesCache.staleAfterHours || 48) * 3600 * 1000;
const shouldRefreshNow = !state.lastSyncAt
|| (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs;
if (shouldRefreshNow && isAssetsSyncConfigured()) {
logger.info('Stores cache is missing or stale; starting background refresh');
refresh().catch(e => logger.error('Initial stores cache refresh failed', { error: e.message }));
} else if (shouldRefreshNow) {
logger.warn('Stores cache is missing or stale but assets sync is not configured; skipping initial refresh');
}
const intervalHours = config.storesCache.refreshIntervalHours;
if (intervalHours > 0) {
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
state.scheduleTimer = setInterval(() => {
if (!isAssetsSyncConfigured()) return;
refresh().catch(e => logger.error('Scheduled stores cache refresh failed', { error: e.message }));
}, intervalHours * 3600 * 1000);
state.scheduleTimer.unref?.(); // don't block process exit for tests
logger.info('Stores cache: periodic refresh scheduled', { intervalHours });
}
}
/**
* Test / shutdown hook. Clears the schedule timer and the in-memory map.
* Does NOT delete the on-disk cache.
*/
export function shutdown() {
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
state.scheduleTimer = null;
state.cache = new Map();
state.lastSyncAt = null;
state.lastError = null;
state.syncing = false;
state.loadedFromDisk = false;
}

View file

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

View file

@ -25,8 +25,15 @@ export {
getTransitions, getTransitions,
transitionTicket, transitionTicket,
closeTicket, closeTicket,
setSSValidatorFields,
} from './jira/issues.js'; } from './jira/issues.js';
export {
confirmFixed,
customerCancelled,
markDuplicate,
} from './jira/closeHelpers.js';
export { export {
fetchPublicComments, fetchPublicComments,
addComment, addComment,
@ -45,9 +52,22 @@ export {
probeAssetsForStore, probeAssetsForStore,
} from './jira/assets.js'; } from './jira/assets.js';
// Stores cache (Assets store number -> objectId), fed by a personal-PAT sync // Assets object caches (Assets objectType -> in-memory map). Fed by a
// to work around the service-account permission block on Object Type 109. // personal-PAT sync to work around the service-account permission block on
// schema 68. See services/jira/caches.js for the barrel and
// assetsObjectCache.js for the shared factory.
// - stores (objectType 109) — resolveStoreAssetReference
// - businessServices (objectType 100) — SS close validator field
// - systems (objectType 103) — SS close validator field
// - causes (objectType 107) — SS close validator field
export * as storesCache from './jira/storesCache.js'; export * as storesCache from './jira/storesCache.js';
export {
allCaches,
cachesByName,
businessServicesCache,
systemsCache,
causesCache,
} from './jira/caches.js';
export { isAssetsSyncConfigured, describeSyncIdentity } from './jira/assetsSyncClient.js'; export { isAssetsSyncConfigured, describeSyncIdentity } from './jira/assetsSyncClient.js';
export { export {
@ -68,7 +88,13 @@ import {
getTransitions, getTransitions,
transitionTicket, transitionTicket,
closeTicket, closeTicket,
setSSValidatorFields,
} from './jira/issues.js'; } from './jira/issues.js';
import {
confirmFixed,
customerCancelled,
markDuplicate,
} from './jira/closeHelpers.js';
import { import {
fetchPublicComments, fetchPublicComments,
addComment, addComment,
@ -103,5 +129,9 @@ export default {
getTransitions, getTransitions,
transitionTicket, transitionTicket,
closeTicket, closeTicket,
setSSValidatorFields,
confirmFixed,
customerCancelled,
markDuplicate,
jiraClient, jiraClient,
}; };