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>
163 lines
6.2 KiB
JavaScript
163 lines
6.2 KiB
JavaScript
// 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 };
|
|
}
|