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_TOKEN=REPLACE_ME
|
||||
|
||||
# Where the local cache lives on disk (JSON). Gitignored. Regenerable via
|
||||
# POST /api/wxccai/admin/storesCache/refresh.
|
||||
STORES_CACHE_PATH=./data/stores.json
|
||||
# How often to run a full resync (hours). 0 disables the scheduler.
|
||||
STORES_CACHE_REFRESH_HOURS=24
|
||||
# Shared settings for all Assets object caches (stores, business services,
|
||||
# systems, causes). Each cache is a Map<key, entry> backed by a JSON file at
|
||||
# ${CACHES_DIR}/${cacheName}.json. Regenerable via
|
||||
# POST /api/wxccai/admin/caches/refreshAll (or refresh a single cache).
|
||||
CACHES_DIR=./data
|
||||
# How often to run a full resync per cache (hours). 0 disables the scheduler.
|
||||
CACHES_REFRESH_HOURS=24
|
||||
# Cache is considered "stale" after this many hours; boot-time refresh fires
|
||||
# if the on-disk snapshot is older than this.
|
||||
STORES_CACHE_STALE_AFTER_HOURS=48
|
||||
CACHES_STALE_AFTER_HOURS=48
|
||||
|
||||
# --- xAI (Grok) ---
|
||||
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? }`
|
||||
- `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)
|
||||
|
||||
|
|
@ -57,16 +60,28 @@ Base path: `/api/wxccai`.
|
|||
|
||||
### Admin
|
||||
|
||||
- `GET /admin/storesCache/status` — snapshot of the local Assets store cache: `{ storeCount, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }`. Safe for health checks.
|
||||
- `POST /admin/storesCache/refresh` — force an immediate resync via the personal PAT (see below). Takes a few seconds and returns the new status.
|
||||
- `GET /admin/caches` — snapshot of all four Assets object caches (stores, businessServices, systems, causes): `{ caches: [{ name, count, lastSyncAt, ageSeconds, syncing, assetsSyncConfigured, ... }] }`. Safe for health checks.
|
||||
- `GET /admin/caches/:name/status` — same shape, one cache.
|
||||
- `POST /admin/caches/:name/refresh` — force an immediate resync of one cache via the personal PAT (see below). Takes a few seconds.
|
||||
- `POST /admin/caches/refreshAll` — refresh every cache in parallel.
|
||||
- `GET /admin/storesCache/status` and `POST /admin/storesCache/refresh` — backward-compat aliases for the stores cache endpoints above.
|
||||
|
||||
### Debug (non-production only)
|
||||
|
||||
- `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.
|
||||
|
||||
|
|
@ -111,12 +126,56 @@ If `ASSETS_SYNC_TOKEN` is already in the process env (Setup A above), the wrappe
|
|||
|
||||
### 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:
|
||||
|
||||
```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
|
||||
|
|
|
|||
14
src/app.js
14
src/app.js
|
|
@ -2,7 +2,7 @@ import express from 'express';
|
|||
import config from './config/index.js';
|
||||
import wxccRoutes from './routes/wxccRoutes.js';
|
||||
import { getDetailedHealth } from './services/healthService.js';
|
||||
import * as storesCache from './services/jira/storesCache.js';
|
||||
import { allCaches } from './services/jira/caches.js';
|
||||
import logger from './utilities/logger.js';
|
||||
|
||||
const app = express();
|
||||
|
|
@ -135,9 +135,13 @@ app.use((req, res) => {
|
|||
app.listen(config.port, () => {
|
||||
logger.info(`Server running on port ${config.port} in ${config.nodeEnv} mode`);
|
||||
|
||||
// Kick off the stores cache: load from disk, schedule periodic refresh,
|
||||
// and (if stale) start a background full refresh. Never blocks startup.
|
||||
storesCache.init().catch(err => {
|
||||
logger.error('storesCache.init failed at boot', { error: err.message });
|
||||
// Kick off all Assets object caches: load from disk, schedule periodic
|
||||
// refresh, and (if stale) start a background full refresh. Never blocks
|
||||
// startup. Each cache runs independently — failure of one doesn't stop
|
||||
// the others.
|
||||
for (const cache of allCaches) {
|
||||
cache.init().catch(err => {
|
||||
logger.error(`cache.init failed at boot`, { cache: cache.name, error: err.message });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -86,18 +86,21 @@ export const config = {
|
|||
assetsSyncToken: process.env.ASSETS_SYNC_TOKEN || null,
|
||||
},
|
||||
|
||||
// Local cache of the Assets Store schema. Backed by a JSON file on disk;
|
||||
// refreshed periodically via the personal PAT above.
|
||||
storesCache: {
|
||||
enabled: boolFromEnv('STORES_CACHE_ENABLED', true),
|
||||
// Where the on-disk snapshot lives. Default is data/stores.json under
|
||||
// the process cwd; gitignored.
|
||||
path: process.env.STORES_CACHE_PATH?.trim()
|
||||
|| path.resolve(process.cwd(), 'data', 'stores.json'),
|
||||
refreshIntervalHours: intFromEnv('STORES_CACHE_REFRESH_HOURS', 24),
|
||||
staleAfterHours: intFromEnv('STORES_CACHE_STALE_AFTER_HOURS', 48),
|
||||
pageSize: intFromEnv('STORES_CACHE_PAGE_SIZE', 500),
|
||||
maxPages: intFromEnv('STORES_CACHE_MAX_PAGES', 200),
|
||||
// Shared config for all Assets object caches (stores, business services,
|
||||
// systems, causes). Each cache is a `Map<key, entry>` backed by a JSON
|
||||
// file on disk at `${dir}/${cacheName}.json`. All caches use the same
|
||||
// personal PAT auth path (see jira.assetsSyncToken above).
|
||||
caches: {
|
||||
enabled: boolFromEnv('CACHES_ENABLED', true),
|
||||
// Directory where on-disk snapshots live. Filenames are auto-derived
|
||||
// per cache: stores.json, businessServices.json, systems.json,
|
||||
// causes.json. Gitignored via data/ in .gitignore.
|
||||
dir: process.env.CACHES_DIR?.trim()
|
||||
|| path.resolve(process.cwd(), 'data'),
|
||||
refreshIntervalHours: intFromEnv('CACHES_REFRESH_HOURS', 24),
|
||||
staleAfterHours: intFromEnv('CACHES_STALE_AFTER_HOURS', 48),
|
||||
pageSize: intFromEnv('CACHES_PAGE_SIZE', 500),
|
||||
maxPages: intFromEnv('CACHES_MAX_PAGES', 200),
|
||||
},
|
||||
|
||||
xai: {
|
||||
|
|
|
|||
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? }
|
||||
// If transitionName is omitted, we auto-pick the first "done" transition.
|
||||
// Body: {
|
||||
// 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) => {
|
||||
const key = req.params.key?.trim().toUpperCase();
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
return res.status(400).json({ error: 'Invalid Jira key' });
|
||||
}
|
||||
const { transitionName, resolution, comment, internal } = req.body || {};
|
||||
const body = req.body || {};
|
||||
try {
|
||||
const result = await jiraService.closeTicket(key, {
|
||||
transitionName,
|
||||
resolution: resolution || 'Done',
|
||||
comment,
|
||||
internal: !!internal
|
||||
transitionName: body.transitionName,
|
||||
// Deliberately NOT defaulting to 'Done' here anymore — closeTicket()
|
||||
// will only send a resolution when the chosen transition is
|
||||
// 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 });
|
||||
} catch (err) {
|
||||
|
|
@ -312,40 +333,152 @@ router.post('/wxccai/ticket/:key/close', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ========================
|
||||
// Stores cache admin
|
||||
// GET /api/wxccai/admin/storesCache/status — snapshot of cache state
|
||||
// POST /api/wxccai/admin/storesCache/refresh — force an immediate resync
|
||||
// Contact Center convenience wrappers over /close. Each takes a small body
|
||||
// with just the intent-specific fields; the rest is defaulted from subType.
|
||||
//
|
||||
// These are admin endpoints (not user-facing). The status endpoint is safe to
|
||||
// hit from a health check; the refresh endpoint triggers an AQL walk of the
|
||||
// entire Store object type via the personal PAT and takes a few seconds on a
|
||||
// warm connection. Refresh is a no-op if credentials aren't configured or a
|
||||
// sync is already in progress.
|
||||
// ========================
|
||||
router.get('/wxccai/admin/storesCache/status', (req, res) => {
|
||||
// Body: { comment?, subType?, component?, businessService?, system?, cause?, internal? }
|
||||
router.post('/wxccai/ticket/:key/confirmFixed', async (req, res) => {
|
||||
const key = req.params.key?.trim().toUpperCase();
|
||||
if (!key || !KEY_RE.test(key)) {
|
||||
return res.status(400).json({ error: 'Invalid Jira key' });
|
||||
}
|
||||
try {
|
||||
res.json(jiraService.storesCache.status());
|
||||
const result = await jiraService.confirmFixed(key, req.body || {});
|
||||
res.json({ success: true, ...result });
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
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 {
|
||||
const result = await jiraService.storesCache.refresh({ force: true });
|
||||
res.json({ success: true, ...result, status: jiraService.storesCache.status() });
|
||||
res.json(c.status());
|
||||
} 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({
|
||||
success: false,
|
||||
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)
|
||||
// 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 { jiraClient, plainTextToAdf } from './client.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+$/;
|
||||
|
||||
|
|
@ -261,12 +263,244 @@ export async function transitionTicket(key, transitionId, { resolution, comment,
|
|||
}
|
||||
|
||||
/**
|
||||
* Convenience: find a "closing" transition and execute it.
|
||||
* Prefers explicit `transitionName` if provided, otherwise picks the first
|
||||
* transition whose target status is in category "done" (Jira's canonical
|
||||
* category for closed/resolved/completed), falling back to a name-based match.
|
||||
* Build the Cloud CMDB request-field value shape for a single object:
|
||||
* [{ id: "<workspaceId>:<objectId>" }]
|
||||
* Matches buildStoreFieldRef in assets.js. See that function's comment for
|
||||
* why this is needed and how DC/Server used a different shape.
|
||||
*/
|
||||
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);
|
||||
if (transitions.length === 0) {
|
||||
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
|
||||
// path used by resolveStoreAssetReference — the raw service-account AQL call
|
||||
// is only a fallback for the (currently-blocked) scenario where the cache is
|
||||
// unavailable and the service account has been granted enough permission to
|
||||
// query the store type directly.
|
||||
// Store objects live in ObjectType 109 (Store Address / Hierarchy) in schema
|
||||
// 68. The `label` on those objects is the 5-digit padded store number
|
||||
// (verified on real objects in the tenant). We also fall back to the "Store
|
||||
// Number" attribute if the label isn't a plain number.
|
||||
//
|
||||
// Storage:
|
||||
// In-memory: Map<paddedStoreNumber, entry>
|
||||
// On-disk: data/stores.json (see config.storesCache.path). Gitignored.
|
||||
//
|
||||
// Lifecycle:
|
||||
// init() — called at boot; loads disk cache and kicks off
|
||||
// background refresh if missing/stale, then schedules
|
||||
// periodic refresh via setInterval.
|
||||
// refresh({force}) — full paginated resync via personal PAT.
|
||||
// get(storeNumber) — sync lookup, returns entry or null.
|
||||
// status() — for the admin endpoint.
|
||||
//
|
||||
// Store objects live in ObjectType 109 in schema 68. The `label` on those
|
||||
// objects is the padded store number (verified against real objects in the
|
||||
// tenant); the `Store Number` attribute is a secondary source we fall back to.
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
// Primary caller: resolveStoreAssetReference in assets.js. Populated by the
|
||||
// personal-PAT sync path because the service account is filtered out of type
|
||||
// 109 (see Forgejo issue #1).
|
||||
import config from '../../config/index.js';
|
||||
import logger from '../../utilities/logger.js';
|
||||
import { assetsAql, isAssetsSyncConfigured, describeSyncIdentity } from './assetsSyncClient.js';
|
||||
|
||||
const state = {
|
||||
cache: new Map(),
|
||||
lastSyncAt: null,
|
||||
lastError: null,
|
||||
syncing: false,
|
||||
scheduleTimer: null,
|
||||
loadedFromDisk: false,
|
||||
};
|
||||
import { createAssetsObjectCache } from './assetsObjectCache.js';
|
||||
|
||||
function normalizeStoreNumber(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
|
|
@ -44,13 +19,12 @@ function normalizeStoreNumber(raw) {
|
|||
return s.padStart(5, '0');
|
||||
}
|
||||
|
||||
function extractStoreNumberFromObject(obj, storeNumberAttrName) {
|
||||
// Primary: the label is the padded store number on type 109 objects.
|
||||
function extractStoreNumberFromObject(obj) {
|
||||
const storeNumberAttrName = config.jira.assetsStoreNumberAttribute || 'Store Number';
|
||||
const label = obj?.label || obj?.name;
|
||||
if (label && /^\d+$/.test(String(label).trim())) {
|
||||
return String(label).trim().padStart(5, '0');
|
||||
}
|
||||
// Secondary: check the named attribute.
|
||||
const attrs = Array.isArray(obj?.attributes) ? obj.attributes : [];
|
||||
for (const attr of attrs) {
|
||||
const meta = attr?.objectTypeAttribute || attr?.typeAttribute || {};
|
||||
|
|
@ -67,262 +41,13 @@ function extractStoreNumberFromObject(obj, storeNumberAttrName) {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync (in-memory) lookup. Returns null on miss; never throws.
|
||||
*/
|
||||
export function get(storeNumber) {
|
||||
const n = normalizeStoreNumber(storeNumber);
|
||||
if (!n) return null;
|
||||
return state.cache.get(n) || null;
|
||||
}
|
||||
const instance = createAssetsObjectCache({
|
||||
name: 'stores',
|
||||
displayName: 'Stores',
|
||||
objectTypeId: config.jira.assetsStoreObjectTypeId || '109',
|
||||
keyFromObject: extractStoreNumberFromObject,
|
||||
normalizeKey: normalizeStoreNumber,
|
||||
});
|
||||
|
||||
/**
|
||||
* Snapshot for the admin status endpoint. Safe to serialize to JSON.
|
||||
*/
|
||||
export function status() {
|
||||
const lastSyncEpoch = state.lastSyncAt ? new Date(state.lastSyncAt).getTime() : null;
|
||||
return {
|
||||
storeCount: state.cache.size,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
ageSeconds: lastSyncEpoch ? Math.floor((Date.now() - lastSyncEpoch) / 1000) : null,
|
||||
syncing: state.syncing,
|
||||
lastError: state.lastError,
|
||||
loadedFromDisk: state.loadedFromDisk,
|
||||
assetsSyncConfigured: isAssetsSyncConfigured(),
|
||||
syncIdentity: describeSyncIdentity(),
|
||||
cachePath: config.storesCache.path,
|
||||
refreshIntervalHours: config.storesCache.refreshIntervalHours,
|
||||
staleAfterHours: config.storesCache.staleAfterHours,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadFromDisk() {
|
||||
const cachePath = config.storesCache.path;
|
||||
try {
|
||||
const raw = await fs.readFile(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === 'object' && parsed.entries) {
|
||||
state.cache = new Map(Object.entries(parsed.entries));
|
||||
state.lastSyncAt = parsed.lastSyncAt || null;
|
||||
state.loadedFromDisk = true;
|
||||
logger.info('Loaded stores cache from disk', {
|
||||
path: cachePath,
|
||||
storeCount: state.cache.size,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
logger.warn('Stores cache file present but shape unexpected; ignoring', { path: cachePath });
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
logger.info('No stores cache on disk yet; will populate on first sync', { path: cachePath });
|
||||
} else {
|
||||
logger.warn('Failed to load stores cache from disk', { path: cachePath, error: e.message });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function saveToDisk() {
|
||||
const cachePath = config.storesCache.path;
|
||||
const dir = path.dirname(cachePath);
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const payload = {
|
||||
version: 1,
|
||||
lastSyncAt: state.lastSyncAt,
|
||||
storeCount: state.cache.size,
|
||||
entries: Object.fromEntries(state.cache),
|
||||
};
|
||||
// Atomic-ish write: to tmp then rename, avoids readers seeing a half-written file.
|
||||
const tmp = `${cachePath}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
||||
await fs.rename(tmp, cachePath);
|
||||
logger.debug('Persisted stores cache to disk', { path: cachePath, storeCount: state.cache.size });
|
||||
} catch (e) {
|
||||
logger.warn('Failed to persist stores cache to disk', { path: cachePath, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full paginated resync of all objects in the configured store object type
|
||||
* via the personal PAT. Rebuilds the in-memory cache atomically (only swaps
|
||||
* once every page has been read successfully) and then writes to disk.
|
||||
*
|
||||
* Never called reentrantly — if a refresh is already in flight, returns
|
||||
* { skipped: true, reason: 'already_syncing' }.
|
||||
*/
|
||||
export async function refresh({ force = false } = {}) {
|
||||
if (state.syncing) {
|
||||
logger.info('Stores cache refresh already in progress; ignoring duplicate call');
|
||||
return { skipped: true, reason: 'already_syncing' };
|
||||
}
|
||||
if (!isAssetsSyncConfigured()) {
|
||||
return { skipped: true, reason: 'not_configured' };
|
||||
}
|
||||
|
||||
state.syncing = true;
|
||||
state.lastError = null;
|
||||
const started = Date.now();
|
||||
const objectTypeId = config.jira.assetsStoreObjectTypeId || '109';
|
||||
const storeNumberAttr = config.jira.assetsStoreNumberAttribute || 'Store Number';
|
||||
const pageSize = config.storesCache.pageSize || 500;
|
||||
const maxPages = config.storesCache.maxPages || 200; // safety cap; 200*500 = 100k stores
|
||||
|
||||
try {
|
||||
const newCache = new Map();
|
||||
// Atlassian Assets `/object/aql` paginates via startAt + maxResults +
|
||||
// isLast. Some responses also cap `total` at 1000 as a hint, so we
|
||||
// trust isLast (or an empty/short page) rather than the reported total.
|
||||
let startAt = 0;
|
||||
let page = 0;
|
||||
let seenTotal = 0;
|
||||
let expectedTotal = null;
|
||||
let orphaned = 0;
|
||||
|
||||
while (page < maxPages) {
|
||||
const r = await assetsAql(`objectTypeId = ${objectTypeId}`, {
|
||||
startAt,
|
||||
maxResults: pageSize,
|
||||
includeAttributes: true,
|
||||
});
|
||||
|
||||
if (r.status !== 200) {
|
||||
const bodySnippet = typeof r.data === 'object'
|
||||
? JSON.stringify(r.data).slice(0, 400)
|
||||
: String(r.data).slice(0, 400);
|
||||
throw new Error(
|
||||
`Assets AQL page ${page + 1} (startAt=${startAt}) returned HTTP ${r.status} (${r.statusText}). Body: ${bodySnippet}`
|
||||
);
|
||||
}
|
||||
|
||||
const values = Array.isArray(r.data?.values) ? r.data.values : [];
|
||||
const returnedMaxResults = typeof r.data?.maxResults === 'number' ? r.data.maxResults : pageSize;
|
||||
if (expectedTotal === null && typeof r.data?.total === 'number') {
|
||||
expectedTotal = r.data.total;
|
||||
}
|
||||
|
||||
for (const obj of values) {
|
||||
const storeNumber = extractStoreNumberFromObject(obj, storeNumberAttr);
|
||||
if (!storeNumber || !obj?.id) {
|
||||
orphaned++;
|
||||
continue;
|
||||
}
|
||||
newCache.set(storeNumber, {
|
||||
objectId: String(obj.id),
|
||||
objectKey: obj.objectKey || null,
|
||||
label: obj.label || obj.name || null,
|
||||
syncedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
seenTotal += values.length;
|
||||
page++;
|
||||
|
||||
const isLast = r.data?.isLast === true
|
||||
|| values.length === 0
|
||||
|| values.length < returnedMaxResults;
|
||||
|
||||
logger.debug('Stores cache sync page', {
|
||||
page,
|
||||
startAt,
|
||||
returnedMaxResults,
|
||||
rowsThisPage: values.length,
|
||||
seenTotal,
|
||||
expectedTotal,
|
||||
mappedSoFar: newCache.size,
|
||||
orphanedSoFar: orphaned,
|
||||
isLast,
|
||||
});
|
||||
|
||||
if (isLast) break;
|
||||
startAt += values.length;
|
||||
}
|
||||
|
||||
if (page >= maxPages) {
|
||||
logger.warn('Stores cache sync hit page cap; some stores may be missing', {
|
||||
maxPages,
|
||||
pageSize,
|
||||
seenTotal,
|
||||
});
|
||||
}
|
||||
|
||||
// Only swap once we've read every page successfully.
|
||||
state.cache = newCache;
|
||||
state.lastSyncAt = new Date().toISOString();
|
||||
await saveToDisk();
|
||||
|
||||
const durationMs = Date.now() - started;
|
||||
logger.info('Stores cache refresh complete', {
|
||||
storeCount: state.cache.size,
|
||||
orphaned,
|
||||
pagesRead: page,
|
||||
durationMs,
|
||||
forced: force,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
storeCount: state.cache.size,
|
||||
orphaned,
|
||||
pagesRead: page,
|
||||
durationMs,
|
||||
};
|
||||
} catch (e) {
|
||||
state.lastError = { message: e.message, at: new Date().toISOString() };
|
||||
logger.error('Stores cache refresh failed', { error: e.message });
|
||||
throw e;
|
||||
} finally {
|
||||
state.syncing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the disk cache, kick off a background refresh if missing or stale, and
|
||||
* schedule periodic refreshes. Idempotent — safe to call more than once.
|
||||
*
|
||||
* Never throws. Individual failures are logged and surfaced through status().
|
||||
*/
|
||||
export async function init() {
|
||||
if (!config.storesCache.enabled) {
|
||||
logger.info('Stores cache disabled via config; skipping init');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadFromDisk();
|
||||
|
||||
const staleAfterMs = (config.storesCache.staleAfterHours || 48) * 3600 * 1000;
|
||||
const shouldRefreshNow = !state.lastSyncAt
|
||||
|| (Date.now() - new Date(state.lastSyncAt).getTime()) > staleAfterMs;
|
||||
|
||||
if (shouldRefreshNow && isAssetsSyncConfigured()) {
|
||||
logger.info('Stores cache is missing or stale; starting background refresh');
|
||||
refresh().catch(e => logger.error('Initial stores cache refresh failed', { error: e.message }));
|
||||
} else if (shouldRefreshNow) {
|
||||
logger.warn('Stores cache is missing or stale but assets sync is not configured; skipping initial refresh');
|
||||
}
|
||||
|
||||
const intervalHours = config.storesCache.refreshIntervalHours;
|
||||
if (intervalHours > 0) {
|
||||
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
|
||||
state.scheduleTimer = setInterval(() => {
|
||||
if (!isAssetsSyncConfigured()) return;
|
||||
refresh().catch(e => logger.error('Scheduled stores cache refresh failed', { error: e.message }));
|
||||
}, intervalHours * 3600 * 1000);
|
||||
state.scheduleTimer.unref?.(); // don't block process exit for tests
|
||||
logger.info('Stores cache: periodic refresh scheduled', { intervalHours });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test / shutdown hook. Clears the schedule timer and the in-memory map.
|
||||
* Does NOT delete the on-disk cache.
|
||||
*/
|
||||
export function shutdown() {
|
||||
if (state.scheduleTimer) clearInterval(state.scheduleTimer);
|
||||
state.scheduleTimer = null;
|
||||
state.cache = new Map();
|
||||
state.lastSyncAt = null;
|
||||
state.lastError = null;
|
||||
state.syncing = false;
|
||||
state.loadedFromDisk = false;
|
||||
}
|
||||
export const { get, status, refresh, init, shutdown } = instance;
|
||||
export default instance;
|
||||
|
|
|
|||
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,
|
||||
transitionTicket,
|
||||
closeTicket,
|
||||
setSSValidatorFields,
|
||||
} from './jira/issues.js';
|
||||
|
||||
export {
|
||||
confirmFixed,
|
||||
customerCancelled,
|
||||
markDuplicate,
|
||||
} from './jira/closeHelpers.js';
|
||||
|
||||
export {
|
||||
fetchPublicComments,
|
||||
addComment,
|
||||
|
|
@ -45,9 +52,22 @@ export {
|
|||
probeAssetsForStore,
|
||||
} from './jira/assets.js';
|
||||
|
||||
// Stores cache (Assets store number -> objectId), fed by a personal-PAT sync
|
||||
// to work around the service-account permission block on Object Type 109.
|
||||
// Assets object caches (Assets objectType -> in-memory map). Fed by a
|
||||
// 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 {
|
||||
allCaches,
|
||||
cachesByName,
|
||||
businessServicesCache,
|
||||
systemsCache,
|
||||
causesCache,
|
||||
} from './jira/caches.js';
|
||||
export { isAssetsSyncConfigured, describeSyncIdentity } from './jira/assetsSyncClient.js';
|
||||
|
||||
export {
|
||||
|
|
@ -68,7 +88,13 @@ import {
|
|||
getTransitions,
|
||||
transitionTicket,
|
||||
closeTicket,
|
||||
setSSValidatorFields,
|
||||
} from './jira/issues.js';
|
||||
import {
|
||||
confirmFixed,
|
||||
customerCancelled,
|
||||
markDuplicate,
|
||||
} from './jira/closeHelpers.js';
|
||||
import {
|
||||
fetchPublicComments,
|
||||
addComment,
|
||||
|
|
@ -103,5 +129,9 @@ export default {
|
|||
getTransitions,
|
||||
transitionTicket,
|
||||
closeTicket,
|
||||
setSSValidatorFields,
|
||||
confirmFixed,
|
||||
customerCancelled,
|
||||
markDuplicate,
|
||||
jiraClient,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue