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:
parent
b4bc6462a3
commit
034e657fcb
15 changed files with 1307 additions and 359 deletions
14
.env.example
14
.env.example
|
|
@ -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
|
||||||
|
|
|
||||||
73
README.md
73
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
16
src/app.js
16
src/app.js
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
142
src/config/ssCloseDefaults.js
Normal file
142
src/config/ssCloseDefaults.js
Normal 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__ };
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
309
src/services/jira/assetsObjectCache.js
Normal file
309
src/services/jira/assetsObjectCache.js
Normal 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 };
|
||||||
|
}
|
||||||
32
src/services/jira/businessServicesCache.js
Normal file
32
src/services/jira/businessServicesCache.js
Normal 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;
|
||||||
23
src/services/jira/caches.js
Normal file
23
src/services/jira/caches.js
Normal 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]));
|
||||||
32
src/services/jira/causesCache.js
Normal file
32
src/services/jira/causesCache.js
Normal 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;
|
||||||
163
src/services/jira/closeHelpers.js
Normal file
163
src/services/jira/closeHelpers.js
Normal 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 };
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
export const { get, status, refresh, init, shutdown } = instance;
|
||||||
* Snapshot for the admin status endpoint. Safe to serialize to JSON.
|
export default instance;
|
||||||
*/
|
|
||||||
export function status() {
|
|
||||||
const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null;
|
|
||||||
return {
|
|
||||||
storeCount: state.cache.size,
|
|
||||||
lastSyncAt: state.lastSyncAt,
|
|
||||||
ageSeconds: lastSyncEpoch ? Math.floor((Date.now() - lastSyncEpoch) / 1000) : null,
|
|
||||||
syncing: state.syncing,
|
|
||||||
lastError: state.lastError,
|
|
||||||
loadedFromDisk: state.loadedFromDisk,
|
|
||||||
assetsSyncConfigured: isAssetsSyncConfigured(),
|
|
||||||
syncIdentity: describeSyncIdentity(),
|
|
||||||
cachePath: config.storesCache.path,
|
|
||||||
refreshIntervalHours: config.storesCache.refreshIntervalHours,
|
|
||||||
staleAfterHours: config.storesCache.staleAfterHours,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFromDisk() {
|
|
||||||
const cachePath = config.storesCache.path;
|
|
||||||
try {
|
|
||||||
const raw = await fs.readFile(cachePath, 'utf8');
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
|
||||||
state.cache = new Map(Object.entries(parsed.entries));
|
|
||||||
state.lastSyncAt = parsed.lastSyncAt || null;
|
|
||||||
state.loadedFromDisk = true;
|
|
||||||
logger.info('Loaded stores cache from disk', {
|
|
||||||
path: cachePath,
|
|
||||||
storeCount: state.cache.size,
|
|
||||||
lastSyncAt: state.lastSyncAt,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
logger.warn('Stores cache file present but shape unexpected; ignoring', { path: cachePath });
|
|
||||||
} catch (e) {
|
|
||||||
if (e.code === 'ENOENT') {
|
|
||||||
logger.info('No stores cache on disk yet; will populate on first sync', { path: cachePath });
|
|
||||||
} else {
|
|
||||||
logger.warn('Failed to load stores cache from disk', { path: cachePath, error: e.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveToDisk() {
|
|
||||||
const cachePath = config.storesCache.path;
|
|
||||||
const dir = path.dirname(cachePath);
|
|
||||||
try {
|
|
||||||
await fs.mkdir(dir, { recursive: true });
|
|
||||||
const payload = {
|
|
||||||
version: 1,
|
|
||||||
lastSyncAt: state.lastSyncAt,
|
|
||||||
storeCount: state.cache.size,
|
|
||||||
entries: Object.fromEntries(state.cache),
|
|
||||||
};
|
|
||||||
// Atomic-ish write: to tmp then rename, avoids readers seeing a half-written file.
|
|
||||||
const tmp = `${cachePath}.tmp`;
|
|
||||||
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
||||||
await fs.rename(tmp, cachePath);
|
|
||||||
logger.debug('Persisted stores cache to disk', { path: cachePath, storeCount: state.cache.size });
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to persist stores cache to disk', { path: cachePath, error: e.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full paginated resync of all objects in the configured store object type
|
|
||||||
* via the personal PAT. Rebuilds the in-memory cache atomically (only swaps
|
|
||||||
* once every page has been read successfully) and then writes to disk.
|
|
||||||
*
|
|
||||||
* Never called reentrantly — if a refresh is already in flight, returns
|
|
||||||
* { skipped: true, reason: 'already_syncing' }.
|
|
||||||
*/
|
|
||||||
export async function refresh({ force = false } = {}) {
|
|
||||||
if (state.syncing) {
|
|
||||||
logger.info('Stores cache refresh already in progress; ignoring duplicate call');
|
|
||||||
return { skipped: true, reason: 'already_syncing' };
|
|
||||||
}
|
|
||||||
if (!isAssetsSyncConfigured()) {
|
|
||||||
return { skipped: true, reason: 'not_configured' };
|
|
||||||
}
|
|
||||||
|
|
||||||
state.syncing = true;
|
|
||||||
state.lastError = null;
|
|
||||||
const started = Date.now();
|
|
||||||
const objectTypeId = config.jira.assetsStoreObjectTypeId || '109';
|
|
||||||
const storeNumberAttr = config.jira.assetsStoreNumberAttribute || 'Store Number';
|
|
||||||
const pageSize = config.storesCache.pageSize || 500;
|
|
||||||
const maxPages = config.storesCache.maxPages || 200; // safety cap; 200*500 = 100k stores
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newCache = new Map();
|
|
||||||
// Atlassian Assets `/object/aql` paginates via startAt + maxResults +
|
|
||||||
// isLast. Some responses also cap `total` at 1000 as a hint, so we
|
|
||||||
// trust isLast (or an empty/short page) rather than the reported total.
|
|
||||||
let startAt = 0;
|
|
||||||
let page = 0;
|
|
||||||
let seenTotal = 0;
|
|
||||||
let expectedTotal = null;
|
|
||||||
let orphaned = 0;
|
|
||||||
|
|
||||||
while (page < maxPages) {
|
|
||||||
const r = await assetsAql(`objectTypeId = ${objectTypeId}`, {
|
|
||||||
startAt,
|
|
||||||
maxResults: pageSize,
|
|
||||||
includeAttributes: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (r.status !== 200) {
|
|
||||||
const bodySnippet = typeof r.data === 'object'
|
|
||||||
? JSON.stringify(r.data).slice(0, 400)
|
|
||||||
: String(r.data).slice(0, 400);
|
|
||||||
throw new Error(
|
|
||||||
`Assets AQL page ${page + 1} (startAt=${startAt}) returned HTTP ${r.status} (${r.statusText}). Body: ${bodySnippet}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = Array.isArray(r.data?.values) ? r.data.values : [];
|
|
||||||
const returnedMaxResults = typeof r.data?.maxResults === 'number' ? r.data.maxResults : pageSize;
|
|
||||||
if (expectedTotal === null && typeof r.data?.total === 'number') {
|
|
||||||
expectedTotal = r.data.total;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const obj of values) {
|
|
||||||
const storeNumber = extractStoreNumberFromObject(obj, storeNumberAttr);
|
|
||||||
if (!storeNumber || !obj?.id) {
|
|
||||||
orphaned++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
newCache.set(storeNumber, {
|
|
||||||
objectId: String(obj.id),
|
|
||||||
objectKey: obj.objectKey || null,
|
|
||||||
label: obj.label || obj.name || null,
|
|
||||||
syncedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
seenTotal += values.length;
|
|
||||||
page++;
|
|
||||||
|
|
||||||
const isLast = r.data?.isLast === true
|
|
||||||
|| values.length === 0
|
|
||||||
|| values.length < returnedMaxResults;
|
|
||||||
|
|
||||||
logger.debug('Stores cache sync page', {
|
|
||||||
page,
|
|
||||||
startAt,
|
|
||||||
returnedMaxResults,
|
|
||||||
rowsThisPage: values.length,
|
|
||||||
seenTotal,
|
|
||||||
expectedTotal,
|
|
||||||
mappedSoFar: newCache.size,
|
|
||||||
orphanedSoFar: orphaned,
|
|
||||||
isLast,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLast) break;
|
|
||||||
startAt += values.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page >= maxPages) {
|
|
||||||
logger.warn('Stores cache sync hit page cap; some stores may be missing', {
|
|
||||||
maxPages,
|
|
||||||
pageSize,
|
|
||||||
seenTotal,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only swap once we've read every page successfully.
|
|
||||||
state.cache = newCache;
|
|
||||||
state.lastSyncAt = new Date().toISOString();
|
|
||||||
await saveToDisk();
|
|
||||||
|
|
||||||
const durationMs = Date.now() - started;
|
|
||||||
logger.info('Stores cache refresh complete', {
|
|
||||||
storeCount: state.cache.size,
|
|
||||||
orphaned,
|
|
||||||
pagesRead: page,
|
|
||||||
durationMs,
|
|
||||||
forced: force,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
storeCount: state.cache.size,
|
|
||||||
orphaned,
|
|
||||||
pagesRead: page,
|
|
||||||
durationMs,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
state.lastError = { message: e.message, at: new Date().toISOString() };
|
|
||||||
logger.error('Stores cache refresh failed', { error: e.message });
|
|
||||||
throw e;
|
|
||||||
} finally {
|
|
||||||
state.syncing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load the disk cache, kick off a background refresh if missing or stale, and
|
|
||||||
* schedule periodic refreshes. Idempotent — safe to call more than once.
|
|
||||||
*
|
|
||||||
* Never throws. Individual failures are logged and surfaced through status().
|
|
||||||
*/
|
|
||||||
export async function init() {
|
|
||||||
if (!config.storesCache.enabled) {
|
|
||||||
logger.info('Stores cache disabled via config; skipping init');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await loadFromDisk();
|
|
||||||
|
|
||||||
const staleAfterMs = (config.storesCache.staleAfterHours || 48) * 3600 * 1000;
|
|
||||||
const shouldRefreshNow = !state.lastSyncAt
|
|
||||||
|| (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs;
|
|
||||||
|
|
||||||
if (shouldRefreshNow && isAssetsSyncConfigured()) {
|
|
||||||
logger.info('Stores cache is missing or stale; starting background refresh');
|
|
||||||
refresh().catch(e => logger.error('Initial stores cache refresh failed', { error: e.message }));
|
|
||||||
} else if (shouldRefreshNow) {
|
|
||||||
logger.warn('Stores cache is missing or stale but assets sync is not configured; skipping initial refresh');
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalHours = config.storesCache.refreshIntervalHours;
|
|
||||||
if (intervalHours > 0) {
|
|
||||||
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
|
|
||||||
state.scheduleTimer = setInterval(() => {
|
|
||||||
if (!isAssetsSyncConfigured()) return;
|
|
||||||
refresh().catch(e => logger.error('Scheduled stores cache refresh failed', { error: e.message }));
|
|
||||||
}, intervalHours * 3600 * 1000);
|
|
||||||
state.scheduleTimer.unref?.(); // don't block process exit for tests
|
|
||||||
logger.info('Stores cache: periodic refresh scheduled', { intervalHours });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test / shutdown hook. Clears the schedule timer and the in-memory map.
|
|
||||||
* Does NOT delete the on-disk cache.
|
|
||||||
*/
|
|
||||||
export function shutdown() {
|
|
||||||
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
|
|
||||||
state.scheduleTimer = null;
|
|
||||||
state.cache = new Map();
|
|
||||||
state.lastSyncAt = null;
|
|
||||||
state.lastError = null;
|
|
||||||
state.syncing = false;
|
|
||||||
state.loadedFromDisk = false;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
30
src/services/jira/systemsCache.js
Normal file
30
src/services/jira/systemsCache.js
Normal 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;
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue