Implemented the customer service requests view

This commit is contained in:
2026-09-22 17:55:38 -03:00
parent 3ee8eb6f21
commit ea164cdcd6
24 changed files with 1158 additions and 20 deletions

View File

@@ -32,12 +32,26 @@ Chromium service worker and Firefox background scripts. The build composes class
factory modules from `scripts/sources.cjs` into one isolated IIFE per execution
context. It serializes `src/content/styles/redwood.css` followed by the scoped
`src/features/debug/styles.css`, `src/features/opportunities/styles.css` and
`src/features/install-base/styles.css` at
`src/features/install-base/styles.css` and `src/features/service-requests/styles.css` at
build time. There is no
runtime module loader, browser-side eval, ES-module requirement or new dependency.
## Tests and Maintenance
The Service Requests action sits immediately after Installed Base below Customer.
Its dialog opens with skeleton rows and lists open SRs for the selected customer.
The background first calls `requestAccountSummary` with ACCP-TS, then uses its
`id` (not `registryId`) in the GET `requestCustomerServiceRequest` URL. The SR
credential is `uiaasHeader.value` from `SPA-ORANGE`, sent verbatim in
`custsr-auth`, without a Bearer prefix, regardless of `uiaasHeader.label`.
Pages of 40 are consolidated using `totalResults`, `count`
and `offset`. Concurrent clicks for the same customer share the in-flight query;
results are not cached, and closed/replaced dialogs ignore late responses.
The table maps Name/Registry ID from `customer`, Product Name from `component`,
and formats status as a badge and dates using the existing date formatter.
Debug contains a Service Requests category; credential headers are masked before
delivery to content. This feature adds no permissions or authentication retries.
The Installed Base action sits beside customer consumption below Customer. It
opens a skeleton dialog, then calls `requestAccountSummary` with ACCP-TS and uses
the returned `registryId` in `requestInstallBase`. The latter uses the SAIB-TS
@@ -65,7 +79,7 @@ duration or error. Cache hits do not create HTTP entries. Product diagnostics
mask authorization and sensitive response headers before messaging the source
tab/frame; this change adds no timeout, retry or authentication behavior.
Debug tabs group Authentication, Opportunities, Products, AI, Consumption and Installed Base
Debug tabs group Authentication, Opportunities, Products, AI, Consumption, Installed Base and Service Requests
requests; unknown request types appear under Other. Only categories with entries
are shown. All preserves the complete timeline. Every tab sorts by request start
time, retaining original request IDs and showing start times in collapsed rows.

View File

@@ -12,6 +12,7 @@ function createMessageTypes() {
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}
@@ -158,7 +159,8 @@ function createAuthConsumerSession({
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
},
debugRequests
};
@@ -502,6 +504,68 @@ function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject
return { requestInstallBase };
}
// Source: src/services/spa/customer-service-requests.js
function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, findAccountPlanId, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/";
const filter = encodeURIComponent(JSON.stringify({ hierarchy: "single", status: "open" }));
async function requestCustomerServiceRequest(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const srAuth = normalizeMessageAuthorization(message.authorizations?.customerServiceRequests, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!srAuth.value) throw new Error("SPA-ORANGE uiaasHeader.value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const accountSummaryId = findAccountPlanId(summary);
if ((typeof accountSummaryId !== "string" && typeof accountSummaryId !== "number") || !String(accountSummaryId).trim()) {
throw new Error("Account id was not found in requestAccountSummary.");
}
const items = [];
let offset = 0;
// This endpoint uses totalResults/count rather than hasMore.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(accountSummaryId))}?customerSRFilter=${filter}&orderBy=creationDate&orderByMode=desc&limit=40&offset=${offset}`;
const result = await fetchJson(url, {
method: "GET", credentials: "include",
// SPA-ORANGE's label may be lead-auth; this provider requires custsr-auth.
headers: { Accept: "application/json", "custsr-auth": srAuth.value }
}, "requestCustomerServiceRequest", debugRequests);
if (!result || !Array.isArray(result.objects)
|| result.objects.some(item => !item || typeof item !== "object" || Array.isArray(item))
|| !Number.isSafeInteger(result.totalResults) || result.totalResults < 0
|| !Number.isSafeInteger(result.count) || result.count !== result.objects.length) {
throw new Error("requestCustomerServiceRequest returned an invalid collection.");
}
if (result.offset !== offset) throw new Error("requestCustomerServiceRequest returned an unexpected offset.");
items.push(...result.objects);
offset += result.count;
if (offset >= result.totalResults) return { ok: true, items, debugRequests };
if (!result.count) throw new Error("requestCustomerServiceRequest pagination made no progress.");
}
throw new Error("requestCustomerServiceRequest pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestCustomerServiceRequest };
}
// Source: src/services/spa/consumption.js
function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS,
@@ -1513,6 +1577,7 @@ function registerBackgroundMessages({
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -1592,6 +1657,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
@@ -1675,6 +1748,12 @@ function createBackgroundApplication(overrides = {}) {
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -1739,12 +1818,13 @@ function createBackgroundApplication(overrides = {}) {
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -1753,6 +1833,7 @@ function createBackgroundApplication(overrides = {}) {
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaCustomerServiceRequests,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

File diff suppressed because one or more lines are too long

View File

@@ -12,6 +12,7 @@ function createMessageTypes() {
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}
@@ -158,7 +159,8 @@ function createAuthConsumerSession({
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
},
debugRequests
};
@@ -502,6 +504,68 @@ function createSpaInstallBase({ ACCOUNT_SUMMARY_URL, fetchJson, findNestedObject
return { requestInstallBase };
}
// Source: src/services/spa/customer-service-requests.js
function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, findAccountPlanId, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/";
const filter = encodeURIComponent(JSON.stringify({ hierarchy: "single", status: "open" }));
async function requestCustomerServiceRequest(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const srAuth = normalizeMessageAuthorization(message.authorizations?.customerServiceRequests, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!srAuth.value) throw new Error("SPA-ORANGE uiaasHeader.value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const accountSummaryId = findAccountPlanId(summary);
if ((typeof accountSummaryId !== "string" && typeof accountSummaryId !== "number") || !String(accountSummaryId).trim()) {
throw new Error("Account id was not found in requestAccountSummary.");
}
const items = [];
let offset = 0;
// This endpoint uses totalResults/count rather than hasMore.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(accountSummaryId))}?customerSRFilter=${filter}&orderBy=creationDate&orderByMode=desc&limit=40&offset=${offset}`;
const result = await fetchJson(url, {
method: "GET", credentials: "include",
// SPA-ORANGE's label may be lead-auth; this provider requires custsr-auth.
headers: { Accept: "application/json", "custsr-auth": srAuth.value }
}, "requestCustomerServiceRequest", debugRequests);
if (!result || !Array.isArray(result.objects)
|| result.objects.some(item => !item || typeof item !== "object" || Array.isArray(item))
|| !Number.isSafeInteger(result.totalResults) || result.totalResults < 0
|| !Number.isSafeInteger(result.count) || result.count !== result.objects.length) {
throw new Error("requestCustomerServiceRequest returned an invalid collection.");
}
if (result.offset !== offset) throw new Error("requestCustomerServiceRequest returned an unexpected offset.");
items.push(...result.objects);
offset += result.count;
if (offset >= result.totalResults) return { ok: true, items, debugRequests };
if (!result.count) throw new Error("requestCustomerServiceRequest pagination made no progress.");
}
throw new Error("requestCustomerServiceRequest pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestCustomerServiceRequest };
}
// Source: src/services/spa/consumption.js
function createSpaConsumption({
CONSUMPTION_TREND_REQUESTS,
@@ -1513,6 +1577,7 @@ function registerBackgroundMessages({
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -1592,6 +1657,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",
@@ -1675,6 +1748,12 @@ function createBackgroundApplication(overrides = {}) {
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -1739,12 +1818,13 @@ function createBackgroundApplication(overrides = {}) {
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -1753,6 +1833,7 @@ function createBackgroundApplication(overrides = {}) {
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaCustomerServiceRequests,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

File diff suppressed because one or more lines are too long

View File

@@ -32,6 +32,7 @@ different from extension storage and is not a safe place for credentials.
| Content | Opportunity query and presentation | Filter sets, sorting, search, request versions, column formatters, dashboard animation |
| Content | Products UI | Current request ID, product map, expanded rows, progress, last update |
| Content | Consumption UI | Plan state, per-plan/period trend cache, chart navigation and selected series |
| Content | Service Requests UI | Customer-specific in-flight queries, skeleton/table modal, stale-response guards; no persistent cache |
| Content | AI summary | Per-opportunity in-flight state and summary; fresh generation on expansion |
| Content | Authentication/debug | General and scoped token promises, SPA authorizations, request logs, visibility flag |
| Background | Message dispatcher | Synchronously registered listener, asynchronous replies using `return true` |
@@ -56,6 +57,7 @@ Production tokens are held in memory, not deliberately persisted by the extensio
| Sales Common | GET `/fscmRestApi/tokenrelay?scopesfq=urn%3Aopc%3Aresource%3Afusion%3Aeeho%3Asales-common%2F` | AI summary Bearer | In-memory `expires_in`, shared in-flight acquisition; at most one summary retry after 401/403 |
| ACCP-TS | SPA consumer service `uiaasHeader.value` | Account summary authorization | Retrieved with the consumer service response; expiry recorded, not automatically acted on |
| OCICONS-TS | SPA consumer service `uiaasHeader.value` | Account plans and consumption trend authorization | Same service response, distinct credential from ACCP-TS |
| SPA-ORANGE | SPA consumer service `uiaasHeader.value` | Customer service requests, fixed `custsr-auth` header with raw value, no Bearer prefix; ignores `uiaasHeader.label` | Optional at consumer acquisition; required only when opening Service Requests; existing in-memory session rules |
Fusion calls use `https://eeho.fa.us2.oraclecloud.com`; SPA calls use
`https://spa.oracle.com/oalcrm/web/api/`. Preserve `credentials: include` and
@@ -68,6 +70,7 @@ operation-specific headers rather than unifying all calls under a default token.
| requestGenerateAiSummary | POST `/api/sales-common/gen-ai/v1/summarizations/generate` | `{path,promptCode,object,puid}`; prompt `cx.sales.generate_opportunity_summary`; only summaryText displayed |
| requestConsumerTokenService | GET `g2m-consumer-application/consumerTokenService` | No-store; parse JSON; HTML 200 recovery via inactive SPA UI tab; maximum five HTTP attempts |
| requestAccountSummary | POST `provider-proxy/g2m-account/service/accountsummary` | ACCP-TS; `[{key:"regId",value:partyNumber}]` |
| requestCustomerServiceRequest | GET `provider-proxy/g2m-custsr-provider/service/oscSr/{accountSummaryId}` | SPA-ORANGE; summary `id`, not `registryId`; encoded filter `{hierarchy:"single",status:"open"}`, creationDate descending, pages of 40 using totalResults/count/offset |
| requestAccountPlans | POST `provider-proxy/g2m-consumption/service/v1/account/accountPlans/{id}` | OCICONS-TS; original hierarchy/products/language/planStatus payload |
| requestConsumptionService{Period} | GET `provider-proxy/g2m-consumption/service/v1/plan/trend/{subPlanNum}?quarter=All&timePeriod={period}` | G2m-Authorization; quarterly/monthly/weekly/daily; weekly default |
@@ -90,7 +93,8 @@ was silently changed during extraction.
| refreshXsrfCookie | type; sender tab context | ok, refreshed, cookieName, durationMs, error |
| requestOpptyProducts | requestId, accessToken, optyNumbers, cacheMaxAgeMs | ok, requestId, completed, total, failed, cacheHits; error envelope on dispatcher failure |
| opptyProductsProgress | Background to sender tab/frame | requestId, state, counters; per-item optyNumber/items/fromCache/cachedAt or error |
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans, debugRequests |
| requestConsumerTokenService | type; sender tab context | ok, authorizations.accountSummary/accountPlans/installBase/customerServiceRequests, debugRequests |
| requestCustomerServiceRequest | partyNumber, authorizations.accountSummary/customerServiceRequests | ok, items (consolidated response.objects), debugRequests; error envelope on failure |
| requestAccountPlans | partyNumber, authorizations | ok, accountPlanId, plans, debugRequests |
| requestConsumptionTrend | subPlanNum, period, authorization | ok, subPlanNum, period, items, debugRequests |

View File

@@ -16,7 +16,7 @@ function composeScript(target, { bootstrap = true } = {}) {
return `// Source: ${file}\n${source}`;
});
if (target === "content") {
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"]
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
parts.unshift(`const redwoodStyles = ${JSON.stringify(css)};`);
}

View File

@@ -42,6 +42,8 @@ module.exports = {
"src/features/consumption/plans-action.js",
"src/features/install-base/action.js",
"src/features/install-base/view.js",
"src/features/service-requests/action.js",
"src/features/service-requests/view.js",
"src/features/consumption/plans-view.js",
"src/features/consumption/trend-controller.js",
"src/features/consumption/chart.js",
@@ -59,6 +61,7 @@ module.exports = {
"src/auth/spa-credentials.js",
"src/services/spa/accounts.js",
"src/services/spa/install-base.js",
"src/services/spa/customer-service-requests.js",
"src/services/spa/consumption.js",
"src/services/spa/response-shapes.js",
"src/services/http/background-json.js",

View File

@@ -59,7 +59,8 @@ function createAuthConsumerSession({
authorizations: {
accountSummary: accountSummaryAuthorization,
accountPlans: accountPlansAuthorization,
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", "")
installBase: extractTokenServiceAuthorization(tokenResponse, "SAIB-TS", ""),
customerServiceRequests: extractTokenServiceAuthorization(tokenResponse, "SPA-ORANGE", "")
},
debugRequests
};

View File

@@ -40,6 +40,12 @@ function createBackgroundApplication(overrides = {}) {
findNestedObject: (...args) => spaResponseShapes.findNestedObject(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaCustomerServiceRequests = createSpaCustomerServiceRequests({
ACCOUNT_SUMMARY_URL: contracts.ACCOUNT_SUMMARY_URL,
fetchJson: (...args) => httpBackgroundJson.fetchJson(...args),
findAccountPlanId: (...args) => spaResponseShapes.findAccountPlanId(...args),
normalizeMessageAuthorization: (...args) => authSpaCredentials.normalizeMessageAuthorization(...args)
});
const spaConsumption = createSpaConsumption({
CONSUMPTION_TREND_REQUESTS: contracts.CONSUMPTION_TREND_REQUESTS,
CONSUMPTION_TREND_URL: contracts.CONSUMPTION_TREND_URL,
@@ -104,12 +110,13 @@ function createBackgroundApplication(overrides = {}) {
const refreshXsrfCookie = (...args) => authXsrfRecovery.refreshXsrfCookie(...args);
const requestAccountPlans = (...args) => spaAccounts.requestAccountPlans(...args);
const requestInstallBase = (...args) => spaInstallBase.requestInstallBase(...args);
const requestCustomerServiceRequest = (...args) => spaCustomerServiceRequests.requestCustomerServiceRequest(...args);
const requestConsumerTokenService = (...args) => authConsumerSession.requestConsumerTokenService(...args);
const requestConsumptionTrend = (...args) => spaConsumption.requestConsumptionTrend(...args);
const requestOpptyProducts = (...args) => productsBackgroundJobs.requestOpptyProducts(...args);
const runtimeApi = environment.browser || environment.chrome;
function start() {
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
startBackgroundApplication({ messageTypes, getXsrfTokenCookie, refreshXsrfCookie, requestAccountPlans, requestInstallBase, requestCustomerServiceRequest, requestConsumerTokenService, requestConsumptionTrend, requestOpptyProducts, runtimeApi });
}
return {
start, contracts,
@@ -118,6 +125,7 @@ function createBackgroundApplication(overrides = {}) {
authSpaCredentials,
spaAccounts,
spaInstallBase,
spaCustomerServiceRequests,
spaConsumption,
spaResponseShapes,
httpBackgroundJson,

View File

@@ -5,6 +5,7 @@ function registerBackgroundMessages({
refreshXsrfCookie,
requestAccountPlans,
requestInstallBase,
requestCustomerServiceRequest,
requestConsumerTokenService,
requestConsumptionTrend,
requestOpptyProducts,
@@ -84,6 +85,14 @@ runtimeApi.runtime.onMessage.addListener((message, sender, sendResponse) => {
return true;
}
if (message.type === messageTypes.requestCustomerServiceRequest) {
requestCustomerServiceRequest(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load service requests.",
debugRequests: Array.isArray(error.debugRequests) ? error.debugRequests : []
}));
return true;
}
if (message.type === messageTypes.requestInstallBase) {
requestInstallBase(message).then(sendResponse).catch(error => sendResponse({
ok: false, error: error.message || "Unable to load installed base.",

View File

@@ -77,6 +77,7 @@ function createContentApplication(overrides = {}) {
authSessionState,
closeAccountPlansModal: (...args) => consumptionPlansView.closeAccountPlansModal(...args),
closeInstallBaseModal: (...args) => installBaseView.closeInstallBaseModal(...args),
closeServiceRequestsModal: (...args) => serviceRequestsView.closeServiceRequestsModal(...args),
closeCustomerFilterPanel: (...args) => opportunitiesFilters.closeCustomerFilterPanel(...args),
closeOwnerFilterPanel: (...args) => opportunitiesFilters.closeOwnerFilterPanel(...args),
closePreferencesModal: (...args) => preferencesView.closePreferencesModal(...args),
@@ -199,6 +200,7 @@ function createContentApplication(overrides = {}) {
OPPORTUNITY_DETAIL_URL: contracts.OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton: (...args) => consumptionPlansAction.createCloudConsumptionButton(...args),
createInstallBaseButton: (...args) => installBaseAction.createInstallBaseButton(...args),
createServiceRequestsButton: (...args) => serviceRequestsAction.createServiceRequestsButton(...args),
createCopyOpportunityButton: (...args) => opportunitiesLinks.createCopyOpportunityButton(...args),
createDetailLink: (...args) => opportunitiesLinks.createDetailLink(...args),
createProductsCountButton: (...args) => productsDetails.createProductsCountButton(...args),
@@ -333,6 +335,20 @@ function createContentApplication(overrides = {}) {
openInstallBaseModal: (...args) => installBaseView.openInstallBaseModal(...args),
environment
});
const serviceRequestsView = createServiceRequestsView({
MODAL_ID: contracts.MODAL_ID,
createCloseIcon: (...args) => contentIcons.createCloseIcon(...args),
formatOracleResponseDate: (...args) => opportunitiesFormatters.formatOracleResponseDate(...args),
environment
});
const serviceRequestsAction = createServiceRequestsAction({
messageTypes,
requestConsumerTokenServiceOnce: (...args) => authContentSession.requestConsumerTokenServiceOnce(...args),
sendRuntimeMessage: (...args) => platformContentMessages.sendRuntimeMessage(...args),
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
openServiceRequestsModal: (...args) => serviceRequestsView.openServiceRequestsModal(...args),
environment
});
const consumptionPlansAction = createConsumptionPlansAction({
messageTypes,
appendBackgroundRequestLog: (...args) => debugContentLog.appendBackgroundRequestLog(...args),
@@ -443,6 +459,8 @@ function createContentApplication(overrides = {}) {
consumptionPlansAction,
installBaseAction,
installBaseView,
serviceRequestsAction,
serviceRequestsView,
consumptionPlansView,
consumptionTrendController,
consumptionChart,

View File

@@ -27,6 +27,7 @@ function createContentModal({
authSessionState,
closeAccountPlansModal,
closeInstallBaseModal,
closeServiceRequestsModal,
closeCustomerFilterPanel,
closeOwnerFilterPanel,
closePreferencesModal,
@@ -434,6 +435,7 @@ function createContentModal({
closePreferencesModal();
closeAccountPlansModal();
closeInstallBaseModal();
closeServiceRequestsModal();
modal.hidden = true;
}
@@ -462,6 +464,10 @@ function createContentModal({
}
if (event.key === "Escape") {
if (closeServiceRequestsModal()) {
event.preventDefault();
return;
}
if (closeInstallBaseModal()) {
event.preventDefault();
return;

View File

@@ -11,7 +11,8 @@ function createDebugContentView({
const categories = [
["all", "All"], ["authentication", "Authentication"],
["opportunities", "Opportunities"], ["products", "Products"],
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"], ["other", "Other"]
["ai", "AI"], ["consumption", "Consumption"], ["installBase", "Installed Base"],
["serviceRequests", "Service Requests"], ["other", "Other"]
];
let selectedCategory = "all";
const expandedIds = new Set();
@@ -22,6 +23,7 @@ function createDebugContentView({
if (entry.name === "requestOpptyProducts") return "products";
if (entry.name === "requestGenerateAiSummary") return "ai";
if (entry.name === "requestInstallBase") return "installBase";
if (entry.name === "requestCustomerServiceRequest") return "serviceRequests";
if (/^request(?:AccountSummary|AccountPlans|ConsumptionService)/.test(entry.name)) return "consumption";
return "other";
}

View File

@@ -72,11 +72,16 @@
}
.opportunities-extension-cell-actions .opportunities-extension-action-button {
flex: 0 0 24px;
width: 24px;
height: 24px;
padding: 2px;
}
.opportunities-extension-table > tbody > tr > td[data-column="customer"] .opportunities-extension-cell-actions {
min-width: 84px;
}
.opportunities-extension-cell-actions .opportunities-extension-action-button svg {
width: 18px;
height: 18px;

View File

@@ -5,6 +5,7 @@ function createOpportunitiesTable({
OPPORTUNITY_DETAIL_URL,
createCloudConsumptionButton,
createInstallBaseButton,
createServiceRequestsButton,
createCopyOpportunityButton,
createDetailLink,
createProductsCountButton,
@@ -149,7 +150,7 @@ function createOpportunitiesTable({
);
const customerActions = document.createElement("span");
customerActions.className = "opportunities-extension-cell-actions";
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item));
customerActions.append(createCloudConsumptionButton(item), createInstallBaseButton(item), createServiceRequestsButton(item));
customerCellContent.append(customerLink, customerActions);
cell.append(customerCellContent);
} else {

View File

@@ -0,0 +1,52 @@
function createServiceRequestsAction({ messageTypes, requestConsumerTokenServiceOnce, sendRuntimeMessage,
appendBackgroundRequestLog, openServiceRequestsModal, environment }) {
const { document } = environment;
const pending = new Map();
async function load(partyNumber) {
const authorizations = await requestConsumerTokenServiceOnce();
const response = await sendRuntimeMessage({
type: messageTypes.requestCustomerServiceRequest, partyNumber,
authorizations: { accountSummary: authorizations.accountSummary, customerServiceRequests: authorizations.customerServiceRequests }
});
appendBackgroundRequestLog(response?.debugRequests);
if (!response?.ok) throw new Error(response?.error || "Unable to load service requests.");
return response.items;
}
function createServiceRequestsButton(opportunity) {
const button = document.createElement("button");
button.type = "button";
button.className = "opportunities-extension-action-button";
button.title = "View customer service requests";
button.setAttribute("aria-label", `View service requests for ${opportunity.CustomerAccount?.PartyUniqueName || "customer"}`);
const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg");
icon.setAttribute("viewBox", "0 0 24 24");
icon.setAttribute("aria-hidden", "true");
icon.setAttribute("focusable", "false");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M4 14v-3a8 8 0 0 1 16 0v3 M4 11H3v7h4v-7H4 M20 11h1v7h-4v-7h3 M20 18v1a2 2 0 0 1-2 2h-6");
path.setAttribute("fill", "none");
path.setAttribute("stroke", "currentColor");
path.setAttribute("stroke-width", "1.7");
path.setAttribute("stroke-linecap", "round");
path.setAttribute("stroke-linejoin", "round");
icon.append(path);
button.append(icon);
button.addEventListener("click", async () => {
const view = openServiceRequestsModal(opportunity, button);
const partyNumber = String(opportunity.CustomerAccount?.PartyNumber || "");
try {
if (!partyNumber) throw new Error("Customer Party Number is unavailable.");
if (!pending.has(partyNumber)) {
pending.set(partyNumber, load(partyNumber).finally(() => pending.delete(partyNumber)));
}
view.render(await pending.get(partyNumber));
} catch (error) {
view.render([], error.message || "Unable to load service requests.");
}
});
return button;
}
return { createServiceRequestsButton };
}

View File

@@ -0,0 +1,34 @@
.opportunities-extension-account-plans-dialog.opportunities-extension-service-requests-dialog { width: min(1700px, 100%); }
.opportunities-extension-service-requests-content { min-width: 0; min-height: 0; overflow: auto; scrollbar-gutter: stable; }
.opportunities-extension-service-requests-message { margin: 16px; font-size: 13px; }
.opportunities-extension-service-requests-table {
width: 100%; min-width: 1200px; table-layout: fixed; border-collapse: collapse; font-size: 12px;
}
.opportunities-extension-service-requests-table col { width: 7%; }
.opportunities-extension-service-requests-table .service-requests-col-name { width: 17%; }
.opportunities-extension-service-requests-table .service-requests-col-id { width: 10%; }
.opportunities-extension-service-requests-table .service-requests-col-severity { width: 5%; }
.opportunities-extension-service-requests-table .service-requests-col-subStatus { width: 14%; }
.opportunities-extension-service-requests-table .service-requests-col-productName { width: 12%; }
.opportunities-extension-service-requests-table th,
.opportunities-extension-service-requests-table td {
padding: 12px 8px; text-align: left; vertical-align: middle; overflow-wrap: anywhere;
border-bottom: 1px solid #e8e5e1; line-height: 1.4;
}
.opportunities-extension-service-requests-table th { position: sticky; top: 0; z-index: 1; background: #faf9f8; }
.opportunities-extension-service-requests-table td[data-field="created"],
.opportunities-extension-service-requests-table td[data-field="lastUpdated"],
.opportunities-extension-service-requests-table td[data-field="id"] { white-space: nowrap; font-variant-numeric: tabular-nums; }
.opportunities-extension-service-request-status {
display: inline-block; padding: 3px 6px; border-radius: 12px; font-size: 10px; font-weight: 600;
color: #5f5a55; background: #eceae8;
}
.opportunities-extension-service-request-status[data-status="OPEN"] { color: #17693a; background: #e1f1e5; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th { background: #353331; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table th,
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-requests-table td { border-color: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-request-status { color: #dedbd7; background: #484541; }
.opportunities-extension-modal[data-theme="dark"] .opportunities-extension-service-request-status[data-status="OPEN"] { color: #b8e4c6; background: #214d34; }
@media (prefers-reduced-motion: reduce) {
.opportunities-extension-service-requests-table .opportunities-extension-account-plans-skeleton-bar { animation: none; }
}

View File

@@ -0,0 +1,121 @@
function createServiceRequestsView({ MODAL_ID, createCloseIcon, formatOracleResponseDate, environment }) {
const { document } = environment;
let active = null;
const columns = [
["name", "Name", item => item.customer?.name],
["registryId", "Registry ID", item => item.customer?.registryId],
["id", "SR Number", item => item.id],
["severity", "Severity", item => item.severity],
["status", "Status", item => item.status],
["subStatus", "Substatus", item => item.subStatus],
["productName", "Product Name", item => item.component?.productName],
["os", "OS", item => item.os], ["csi", "CSI", item => item.csi],
["created", "Creation date", item => item.created],
["lastUpdated", "Modified", item => item.lastUpdated]
];
function element(tag, className, text) {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function closeServiceRequestsModal() {
if (!active) return false;
const previous = active;
active = null;
previous.modal.remove();
if (previous.trigger?.isConnected) previous.trigger.focus();
return true;
}
function createTable(items, loading = false) {
const table = element("table", "opportunities-extension-service-requests-table");
table.setAttribute("aria-label", "Customer service requests");
const group = element("colgroup");
const head = element("thead");
const header = element("tr");
columns.forEach(([key, label]) => {
group.append(element("col", `service-requests-col-${key}`));
const th = element("th", "", label);
th.scope = "col";
header.append(th);
});
head.append(header);
const body = element("tbody");
(loading ? Array.from({ length: 8 }, () => ({})) : items).forEach(item => {
const row = element("tr");
columns.forEach(([key, , getValue]) => {
const cell = element("td");
cell.dataset.field = key;
if (loading) {
const bar = element("span", "opportunities-extension-account-plans-skeleton-bar");
bar.setAttribute("aria-hidden", "true");
cell.append(bar);
} else {
const value = getValue(item);
let text = value === null || value === undefined || value === "" ? "-" : String(value);
if (text !== "-" && (key === "created" || key === "lastUpdated")) text = formatOracleResponseDate(value);
if (key === "status") {
const badge = element("span", "opportunities-extension-service-request-status", text);
badge.dataset.status = text.trim().toUpperCase();
cell.append(badge);
} else cell.textContent = text;
}
row.append(cell);
});
body.append(row);
});
table.append(group, head, body);
return table;
}
function openServiceRequestsModal(opportunity, trigger) {
closeServiceRequestsModal();
const overlay = document.getElementById(MODAL_ID);
if (!overlay || overlay.hidden) return { render() {} };
const modal = element("section", "opportunities-extension-account-plans-modal");
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
modal.setAttribute("aria-busy", "true");
modal.setAttribute("aria-labelledby", "opportunities-extension-service-requests-title");
const dialog = element("div", "opportunities-extension-account-plans-dialog opportunities-extension-service-requests-dialog");
const header = element("header", "opportunities-extension-account-plans-header");
const heading = element("div");
const title = element("h2", "", "Service Requests");
title.id = "opportunities-extension-service-requests-title";
heading.append(title, element("p", "", opportunity.CustomerAccount?.PartyUniqueName || "Customer"));
const close = element("button", "opportunities-extension-icon-button");
close.type = "button";
close.title = "Close";
close.setAttribute("aria-label", "Close service requests");
close.append(createCloseIcon());
close.addEventListener("click", closeServiceRequestsModal);
header.append(heading, close);
const content = element("div", "opportunities-extension-service-requests-content");
content.tabIndex = 0;
content.setAttribute("role", "region");
content.setAttribute("aria-label", "Service requests results");
const status = element("p", "opportunities-extension-service-requests-message", "Loading service requests...");
status.setAttribute("role", "status");
content.append(status, createTable([], true));
dialog.append(header, content);
modal.append(dialog);
overlay.append(modal);
active = { modal, trigger };
modal.addEventListener("keydown", event => {
if (event.key === "Tab") {
event.preventDefault();
(document.activeElement === close ? content : close).focus();
}
});
close.focus();
return { render(items, error = "") {
if (active?.modal !== modal || overlay.hidden) return;
modal.setAttribute("aria-busy", "false");
content.textContent = "";
status.textContent = error || (items.length ? `${items.length} service requests` : "No open service requests found for this customer.");
status.setAttribute("role", error ? "alert" : "status");
content.append(status);
if (!error && items.length) content.append(createTable(items));
} };
}
return { openServiceRequestsModal, closeServiceRequestsModal, createTable };
}

View File

@@ -0,0 +1,60 @@
function createSpaCustomerServiceRequests({ ACCOUNT_SUMMARY_URL, fetchJson, findAccountPlanId, normalizeMessageAuthorization }) {
const endpoint = "https://spa.oracle.com/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/";
const filter = encodeURIComponent(JSON.stringify({ hierarchy: "single", status: "open" }));
async function requestCustomerServiceRequest(message) {
const debugRequests = [];
try {
const partyNumber = String(message.partyNumber || "").trim();
const summaryAuth = normalizeMessageAuthorization(message.authorizations?.accountSummary, "account-ti-authorization");
const srAuth = normalizeMessageAuthorization(message.authorizations?.customerServiceRequests, "");
if (!partyNumber) throw new Error("Customer Party Number is required.");
if (!summaryAuth.value) throw new Error("ACCP-TS authorization is unavailable.");
if (!srAuth.value) throw new Error("SPA-ORANGE uiaasHeader.value is unavailable.");
const summary = await fetchJson(ACCOUNT_SUMMARY_URL, {
method: "POST", credentials: "include",
headers: { Accept: "application/json", "Content-Type": "application/json", [summaryAuth.name]: summaryAuth.value },
body: JSON.stringify([{ key: "regId", value: partyNumber }])
}, "requestAccountSummary", debugRequests);
const accountSummaryId = findAccountPlanId(summary);
if ((typeof accountSummaryId !== "string" && typeof accountSummaryId !== "number") || !String(accountSummaryId).trim()) {
throw new Error("Account id was not found in requestAccountSummary.");
}
const items = [];
let offset = 0;
// This endpoint uses totalResults/count rather than hasMore.
for (let page = 0; page < 1000; page += 1) {
const url = `${endpoint}${encodeURIComponent(String(accountSummaryId))}?customerSRFilter=${filter}&orderBy=creationDate&orderByMode=desc&limit=40&offset=${offset}`;
const result = await fetchJson(url, {
method: "GET", credentials: "include",
// SPA-ORANGE's label may be lead-auth; this provider requires custsr-auth.
headers: { Accept: "application/json", "custsr-auth": srAuth.value }
}, "requestCustomerServiceRequest", debugRequests);
if (!result || !Array.isArray(result.objects)
|| result.objects.some(item => !item || typeof item !== "object" || Array.isArray(item))
|| !Number.isSafeInteger(result.totalResults) || result.totalResults < 0
|| !Number.isSafeInteger(result.count) || result.count !== result.objects.length) {
throw new Error("requestCustomerServiceRequest returned an invalid collection.");
}
if (result.offset !== offset) throw new Error("requestCustomerServiceRequest returned an unexpected offset.");
items.push(...result.objects);
offset += result.count;
if (offset >= result.totalResults) return { ok: true, items, debugRequests };
if (!result.count) throw new Error("requestCustomerServiceRequest pagination made no progress.");
}
throw new Error("requestCustomerServiceRequest pagination limit reached.");
} catch (error) {
error.debugRequests = debugRequests;
throw error;
} finally {
for (const entry of debugRequests) {
for (const key of Object.keys(entry.requestHeaders || {})) {
if (!/^(accept|content-type)$/i.test(key)) entry.requestHeaders[key] = "(present)";
}
}
}
}
return { requestCustomerServiceRequest };
}

View File

@@ -9,6 +9,7 @@ function createMessageTypes() {
requestConsumerTokenService: "opportunitiesExtension.requestConsumerTokenService",
requestAccountPlans: "opportunitiesExtension.requestAccountPlans",
requestInstallBase: "opportunitiesExtension.requestInstallBase",
requestCustomerServiceRequest: "opportunitiesExtension.requestCustomerServiceRequest",
requestConsumptionTrend: "opportunitiesExtension.requestConsumptionTrend"
});
}

View File

@@ -27,6 +27,7 @@ test("request categories cover every current family and unknown requests stay vi
requestConsumerTokenService: "authentication", requestCustomerTokenService: "authentication",
requestOpportunities: "opportunities", requestOpptyProducts: "products", requestGenerateAiSummary: "ai",
requestAccountSummary: "consumption", requestAccountPlans: "consumption",
requestInstallBase: "installBase", requestCustomerServiceRequest: "serviceRequests",
requestConsumptionServiceQuarterly: "consumption", requestConsumptionServiceMonthly: "consumption",
requestConsumptionServiceWeekly: "consumption", requestConsumptionServiceDaily: "consumption", futureRequest: "other"
})) assert.equal(h.app.debugContentView.getRequestCategory({ name }), category, name);
@@ -93,7 +94,7 @@ test("tabs support arrows, Home and End with a single selected tab and labelled
test("scoped debug styles are packaged after the unchanged main stylesheet", () => {
const root = path.join(__dirname, "..");
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"]
const css = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"]
.map(file => fs.readFileSync(path.join(root, file), "utf8")).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(css)};`));
assert.ok(!composeScript("background").includes("opportunities-extension-debug-tabs"));

View File

@@ -62,6 +62,7 @@ test("ten columns retain their links and put product/customer controls below the
assert.ok(customer.children[0].href.endsWith("party%2Fid"));
assert.equal(customer.children[1].children[0].title, "View customer consumption");
assert.equal(customer.children[1].children[1].title, "View customer installed base");
assert.equal(customer.children[1].children[2].title, "View customer service requests");
assert.equal(findClass(h.cell("optyNumber"), "opportunities-extension-copy-button").title, "Copy opportunity number");
assert.equal(findClass(h.cell("name"), "opportunities-extension-action-button"), undefined);
assert.equal(findClass(h.cell("customer"), "opportunities-extension-products-count"), undefined);
@@ -146,6 +147,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
assert.match(actions, /position: absolute/);
assert.match(actions, /bottom: 10px/);
assert.match(actions, /height: 24px/);
assert.match(css, /min-width: 84px/);
assert.match(css, /flex: 0 0 24px/);
assert.match(css, /td\[data-column="customer"\] \{\s*position: relative;/);
assert.match(css, /grid-template-columns: minmax\(0, 1fr\) 12px/);
assert.doesNotMatch(css, /text-overflow: ellipsis|overflow: hidden|th:nth-child|td:nth-child/);
@@ -153,7 +156,8 @@ test("table styles reserve compact widths, prioritize Name/Customer and keep nes
const remaining = width - fixed;
assert.equal(fixed + remaining / 2 + remaining / 2, width);
assert.ok(remaining > 0);
assert.ok(remaining / 2 >= 12 + 84, "three customer controls fit after the left inset");
}
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css"].map(read).join("\n");
const allCss = ["src/content/styles/redwood.css", "src/features/debug/styles.css", "src/features/opportunities/styles.css", "src/features/install-base/styles.css", "src/features/service-requests/styles.css"].map(read).join("\n");
assert.ok(composeScript("content").includes(`const redwoodStyles = ${JSON.stringify(allCss)};`));
});

View File

@@ -0,0 +1,222 @@
const assert = require("node:assert/strict");
const { test } = require("node:test");
const { loadBackground, response, plain } = require("./support/characterization.cjs");
const { loadApplication } = require("./support/application.cjs");
const fs = require("node:fs");
const path = require("node:path");
const authorizations = {
accountSummary: { name: "Account-Ti-Authorization", value: "synthetic-summary-token" },
customerServiceRequests: { name: "lead-auth", value: "synthetic-sr-token" }
};
const message = { type: "opportunitiesExtension.requestCustomerServiceRequest", partyNumber: "customer-test", authorizations };
const tokens = [
{ tokenService: "ACCP-TS", uiaasHeader: { label: "Account-Ti-Authorization", value: "synthetic-summary-token" } },
{ tokenService: "OCICONS-TS", uiaasHeader: { value: "synthetic-consumption-token" } },
{ tokenService: "SAIB-TS", uiaasHeader: { value: "synthetic-install-token" } },
{ tokenService: "SPA-ORANGE", uiaasHeader: { label: "lead-auth", value: "synthetic-sr-token" } }
];
for (const family of ["chrome", "firefox"]) {
test(`${family}: SPA-ORANGE selection, summary id (not registryId), GET and pagination`, async () => {
const calls = [];
const h = loadBackground({ family, fetch: async (url, options) => {
calls.push({ url, options });
if (url.endsWith("consumerTokenService")) return response(tokens);
if (url.endsWith("accountsummary")) return response({ id: "account/id", registryId: "WRONG-ID" });
const offset = Number(new URL(url).searchParams.get("offset"));
const count = offset === 0 ? 40 : 1;
return response({ totalResults: 41, offset, count, limit: 40,
objects: Array.from({ length: count }, (_, i) => ({ id: `SR-${offset + i}` })) });
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.deepEqual(auth.authorizations.customerServiceRequests, { ...authorizations.customerServiceRequests, expires: "" });
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
assert.equal(result.ok, true);
assert.equal(result.items.length, 41);
assert.equal(calls[1].options.method, "POST");
assert.equal(calls[1].options.headers[authorizations.accountSummary.name], authorizations.accountSummary.value);
assert.deepEqual(JSON.parse(calls[1].options.body), [{ key: "regId", value: "customer-test" }]);
for (const [index, call] of calls.slice(2).entries()) {
const url = new URL(call.url);
assert.equal(url.pathname, "/oalcrm/web/api/provider-proxy/g2m-custsr-provider/service/oscSr/account%2Fid");
assert.deepEqual(JSON.parse(url.searchParams.get("customerSRFilter")), { hierarchy: "single", status: "open" });
assert.equal(url.searchParams.get("orderBy"), "creationDate");
assert.equal(url.searchParams.get("orderByMode"), "desc");
assert.equal(url.searchParams.get("limit"), "40");
assert.equal(url.searchParams.get("offset"), String(index * 40));
assert.equal(call.options.method, "GET");
assert.equal(call.options.body, undefined);
assert.equal(call.options.credentials, "include");
assert.deepEqual(plain(call.options.headers), {
Accept: "application/json", "custsr-auth": authorizations.customerServiceRequests.value
}, "send the raw SPA-ORANGE value in custsr-auth, never lead-auth or Authorization/Bearer");
}
assert.deepEqual(result.debugRequests.map(entry => entry.name), ["requestAccountSummary", "requestCustomerServiceRequest", "requestCustomerServiceRequest"]);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-(sr|summary)-token/);
assert.ok(result.debugRequests.every(entry => entry.httpStatus === 200));
assert.equal(result.debugRequests[1].requestHeaders["custsr-auth"], "(present)");
});
}
test("SR authentication uses SPA-ORANGE value even when its header label is absent", async () => {
let srCalls = 0;
const h = loadBackground({ fetch: async (url, options) => {
if (url.endsWith("consumerTokenService")) return response(tokens.map(token => token.tokenService === "SPA-ORANGE"
? { tokenService: "SPA-ORANGE", uiaasHeader: { value: "synthetic-sr-token" } } : token));
if (url.endsWith("accountsummary")) return response({ id: "account-test" });
srCalls += 1;
assert.deepEqual(plain(options.headers), { Accept: "application/json", "custsr-auth": "synthetic-sr-token" });
return response({ totalResults: 0, offset: 0, count: 0, objects: [] });
} });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
assert.equal(result.ok, true);
assert.equal(srCalls, 1);
});
test("zero and exact page-size totals stop without an extra request", async () => {
for (const total of [0, 40]) {
let calls = 0;
const h = loadBackground({ fetch: async url => {
calls += 1;
if (url.endsWith("accountsummary")) return response([{ id: "summary-id" }]);
return response({ totalResults: total, offset: 0, count: total, objects: Array.from({ length: total }, (_, i) => ({ id: `SR-${i}` })) });
} });
const result = await h.dispatch(message);
assert.equal(result.ok, true);
assert.equal(result.items.length, total);
assert.equal(calls, 2);
}
});
test("missing SPA-ORANGE stays optional for existing features and fails only the new action", async () => {
const h = loadBackground({ fetch: async () => response(tokens.filter(token => token.tokenService !== "SPA-ORANGE")) });
const auth = await h.dispatch({ type: "opportunitiesExtension.requestConsumerTokenService" });
assert.equal(auth.ok, true);
const result = await h.dispatch({ ...message, authorizations: auth.authorizations });
assert.equal(result.ok, false);
assert.match(result.error, /SPA-ORANGE/);
assert.equal(result.debugRequests.length, 0);
});
test("missing identifiers, stalled pages, malformed collections, network and HTTP failures are visible", async () => {
for (const kind of ["id", "stalled", "offset", "count", "total", "objects", "http", "json", "network"]) {
const h = loadBackground({ fetch: async url => {
if (url.endsWith("accountsummary")) return response(kind === "id" ? { registryId: "wrong" } : { id: "account-id" });
if (kind === "http") return response({}, 401);
if (kind === "json") return new Response("<html>fallback</html>");
if (kind === "network") throw new Error("Synthetic network failure");
const page = { totalResults: 1, offset: 0, count: 0, objects: [] };
if (kind === "offset") page.offset = 40;
if (kind === "count") page.count = 1;
if (kind === "total") delete page.totalResults;
if (kind === "objects") page.objects = null;
return response(page);
} });
const result = await h.dispatch(message);
assert.equal(result.ok, false, kind);
assert.ok(result.error, kind);
assert.equal(result.debugRequests.length, kind === "id" ? 1 : 2);
assert.doesNotMatch(JSON.stringify(result.debugRequests), /synthetic-(sr|summary)-token/);
}
});
class Element {
constructor(tag, document) { this.tag = tag; this.document = document; this.children = []; this.dataset = {}; this.attributes = {}; this.listeners = {}; }
append(...nodes) { nodes.forEach(node => { node.parent = this; }); this.children.push(...nodes); }
set textContent(value) { this.text = value; this.children = []; }
get textContent() { return this.text || ""; }
setAttribute(key, value) { this.attributes[key] = value; }
addEventListener(key, fn) { this.listeners[key] = fn; }
focus() { this.document.activeElement = this; }
remove() { if (this.parent) this.parent.children = this.parent.children.filter(node => node !== this); this.parent = null; }
get isConnected() { return Boolean(this.parent); }
click() { return this.listeners.click(); }
}
const walk = node => [node, ...node.children.flatMap(walk)];
function setup() {
const document = { createElement: tag => new Element(tag, document), createElementNS: (_, tag) => new Element(tag, document),
getElementById: id => id === app.contracts.MODAL_ID ? overlay : null };
const overlay = new Element("main", document);
const app = loadApplication("content", { document, Intl, Date });
return { app, overlay, document };
}
const customer = { CustomerAccount: { PartyNumber: "customer-test", PartyUniqueName: "Synthetic customer" } };
test("skeleton, exact field mapping, safe text, dates, badge, empty/error and focus states", () => {
const { app, overlay, document } = setup();
const trigger = new Element("button", document);
overlay.append(trigger);
const view = app.serviceRequestsView.openServiceRequestsModal(customer, trigger);
const modal = overlay.children[1];
assert.equal(modal.attributes["aria-busy"], "true");
assert.equal(walk(modal).filter(node => node.tag === "td").length, 88);
assert.deepEqual(walk(modal).filter(node => node.tag === "th").map(node => node.textContent), [
"Name", "Registry ID", "SR Number", "Severity", "Status", "Substatus", "Product Name", "OS", "CSI", "Creation date", "Modified"
]);
view.render([{ customer: { name: "<script>not HTML</script>", registryId: "reg-1" }, id: "SR-1", severity: "3", status: "Open",
subStatus: "Work In Progress", component: { productName: null }, os: ": ", csi: "Unspecified", created: "2026-03-04", lastUpdated: "2026-09-17" }]);
assert.equal(modal.attributes["aria-busy"], "false");
const cells = walk(modal).filter(node => node.tag === "td");
assert.equal(cells.length, 11);
assert.equal(cells[0].textContent, "<script>not HTML</script>");
assert.equal(cells[0].children.length, 0);
assert.equal(cells[1].textContent, "reg-1");
assert.equal(cells[2].textContent, "SR-1");
assert.equal(cells[4].children[0].dataset.status, "OPEN");
assert.equal(cells[5].textContent, "Work In Progress");
assert.equal(cells[6].textContent, "-");
assert.equal(cells[9].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-03-04"));
assert.equal(cells[10].textContent, app.opportunitiesFormatters.formatOracleResponseDate("2026-09-17"));
modal.listeners.keydown({ key: "Tab", preventDefault() {} });
assert.equal(document.activeElement.attributes.role, "region");
view.render([]);
assert.ok(walk(modal).some(node => node.textContent === "No open service requests found for this customer."));
view.render([], "Synthetic failure");
assert.ok(walk(modal).some(node => node.attributes.role === "alert" && node.textContent === "Synthetic failure"));
assert.equal(app.serviceRequestsView.closeServiceRequestsModal(), true);
assert.equal(document.activeElement, trigger);
view.render([{ id: "Late" }]);
assert.equal(overlay.children.length, 1);
});
test("rapid clicks share the query; different customers and late responses stay isolated", async () => {
const { app, overlay } = setup();
const requests = [];
const logs = [];
app.authContentSession.requestConsumerTokenServiceOnce = async () => authorizations;
app.platformContentMessages.sendRuntimeMessage = request => new Promise(resolve => requests.push({ request, resolve }));
app.debugContentLog.appendBackgroundRequestLog = entries => logs.push(entries);
const button = app.serviceRequestsAction.createServiceRequestsButton(customer);
const first = button.click();
assert.equal(overlay.children[0].attributes["aria-busy"], "true");
const repeated = button.click();
const otherButton = app.serviceRequestsAction.createServiceRequestsButton({ CustomerAccount: { PartyNumber: "other-customer" } });
const second = otherButton.click();
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 2);
assert.deepEqual(plain(requests[0].request), message);
requests[1].resolve({ ok: true, items: [{ id: "Other SR" }], debugRequests: [] });
await second;
requests[0].resolve({ ok: true, items: [{ id: "Wrong customer" }], debugRequests: [] });
await Promise.all([first, repeated]);
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Other SR"));
assert.equal(walk(overlay.children[0]).some(node => node.textContent === "Wrong customer"), false);
assert.equal(logs.length, 2);
const retry = button.click();
await new Promise(resolve => setImmediate(resolve));
assert.equal(requests.length, 3, "results are not cached after completion");
requests[2].resolve({ ok: false, error: "Synthetic request failure", debugRequests: [] });
await retry;
assert.ok(walk(overlay.children[0]).some(node => node.textContent === "Synthetic request failure"));
});
test("styles allocate 100 percent, support dark mode and reduced motion, without hiding values", () => {
const css = fs.readFileSync(path.join(__dirname, "../src/features/service-requests/styles.css"), "utf8");
const overrides = [...css.matchAll(/\.service-requests-col-\w+ \{ width: (\d+)%; \}/g)].map(match => Number(match[1]));
assert.equal(overrides.reduce((a, b) => a + b, 0) + (11 - overrides.length) * 7, 100);
assert.match(css, /min-width: 1200px/);
assert.match(css, /data-theme="dark"/);
assert.match(css, /prefers-reduced-motion/);
assert.doesNotMatch(css, /text-overflow: ellipsis/);
});