diff --git a/memory-bank/change-log.md b/memory-bank/change-log.md index 3db3956c..7a788d60 100644 --- a/memory-bank/change-log.md +++ b/memory-bank/change-log.md @@ -425,3 +425,54 @@ Validation: Follow-ups: - Optional: apply equivalent modernization to any remaining relay-backed handlers outside `pages/api/file/` that still use raw axios promise chains and have no explicit phase21 contract assertions. + +--- + +### CL-011: TASK22224 aggressive non-file bundle (email/admin/endpoint parity) + +date: 2026-03-23 +author: Cline +scope: `pages/api/email/{getmailinglist,getcaseref,notify}.js`, `pages/api/admin/{getnewappeals_api,getlatestdocuments_api}.js`, `pages/api/endpoint/getportallogin_api.js`, `tests/phase21/endpoint-handler-contract.test.cjs` +type: change +rationale: Execute requested aggressive bundling for remaining non-file modernization/parity candidates: remove legacy promise chains and improve hash compatibility on login endpoint while preserving existing contracts. +impact: Improves consistency and resilience across email/admin/endpoint routes with no contract regressions; adds encoded hash-variant compatibility for portal login hash checks. +status: completed + +Summary: + +- `pages/api/email/getmailinglist.js` + - converted axios `.then/.catch` to `try/catch` + - preserved flattening behavior and error contract `MAILING_LIST_FETCH_FAILED` +- `pages/api/email/getcaseref.js` + - converted axios `.then/.catch` to `try/catch` + - preserved flattening behavior and error contract `CASE_REF_FETCH_FAILED` +- `pages/api/email/notify.js` + - converted notify client `.then/.catch` to `try/catch` + - preserved success payload and error contract `EMAIL_NOTIFY_FAILED` +- `pages/api/admin/getnewappeals_api.js` + - removed unused `CryptoJS` import + - converted axios `.then/.catch` to `try/catch` + - preserved `@odata.nextLink` normalization and error contract `ADMIN_NEW_APPEALS_FETCH_FAILED` +- `pages/api/admin/getlatestdocuments_api.js` + - converted axios `.then/.catch` to `try/catch` + - preserved flatten/enrich behavior and error contract `ADMIN_LATEST_DOCS_FETCH_FAILED` +- `pages/api/endpoint/getportallogin_api.js` + - retained required query/hash guards + - expanded hash validation to accept raw + encoded `emailAddress` query-path candidates + - preserved error contract `PORTAL_LOGIN_FETCH_FAILED` +- phase21 endpoint tests expanded: + - `getportallogin` encoded hash variant success path + - `getnewappeals_api` catch contract + - `getlatestdocuments_api` catch contract + +Validation: + +- `node tests/phase21/api-contract-slice1.test.cjs` -> pass + - helper: 4/4 + - file-handler: 49/49 + - email-handler: 12/12 + - endpoint-handler: 152/152 + +Follow-ups: + +- Remaining major modernization candidate is `pages/api/file/generateappealpdf.js` (+ optional `pages/api/file/generatepdf.js`) if we continue final closure slices. diff --git a/pages/api/admin/getlatestdocuments_api.js b/pages/api/admin/getlatestdocuments_api.js index 686352b3..a699550a 100644 --- a/pages/api/admin/getlatestdocuments_api.js +++ b/pages/api/admin/getlatestdocuments_api.js @@ -43,18 +43,18 @@ const encryptDocReference = (documentRef) => { }; export default async function ApiProxy(req, res) { - var pageNumber = req.query.pageNumber || 1; - var token = await getToken(); + const pageNumber = req.query.pageNumber || 1; + const token = await getToken(); - var orderby = req.query.orderby || "createdon"; - var fieldSort = req.query.fieldSort || "desc"; - var showNumberOfRecords = req.query.showNumberOfRecords || 10; - var documentType = req.query.documentType || "all"; - var documentOrigin = req.query.documentOrigin || "all"; - var numberOfWeeks = req.query.numberWeeks || 1; + const orderby = req.query.orderby || "createdon"; + const fieldSort = req.query.fieldSort || "desc"; + const showNumberOfRecords = req.query.showNumberOfRecords || 10; + const documentType = req.query.documentType || "all"; + const documentOrigin = req.query.documentOrigin || "all"; + const numberOfWeeks = req.query.numberWeeks || 1; - var docTypeQueryString = ""; - var docOriginQueryString = ""; + let docTypeQueryString = ""; + let docOriginQueryString = ""; if (documentType != "all") { if (documentType.indexOf(",") >= 0) { @@ -108,7 +108,7 @@ export default async function ApiProxy(req, res) { return resultDate.toISOString().replace(/\.000Z$/, "Z"); // format as 'YYYY-MM-DDT00:00:00Z' } - var queryUrl = + const queryUrl = "pinswg_documents?$select=pinswg_name,createdon,pinswg_publishtoweb,_pinswg_documentids_value,pinswg_isharedocumentlocations,pinswg_isharedocumentreference,pinswg_uploadurl,pinswg_uploadstatus,pinswg_origin&$filter=createdon ge " + daysAgoISO(numberOfWeeks) + docTypeQueryString + @@ -131,40 +131,37 @@ export default async function ApiProxy(req, res) { "\n==========================================\n" ); - var apiResponse = axios - .get( + try { + const { data } = await axios.get( WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), azureHeadersPagedCustom(token.access_token, showNumberOfRecords) - ) - .then(({ data }) => { - let flattened = data.value.map((r) => ({ - ...r, - ticketnumber: r.pinswg_DocumentIds?.ticketnumber || null, - publishtoweb: r.pinswg_DocumentIds?.pinswg_publishtoweb || null - })); + ); - data.value = flattened; - data.value.forEach(function (element) { - element.pinswg_hashlink = encryptDocReference( - element.pinswg_isharedocumentreference - ); - }); + const flattened = data.value.map((r) => ({ + ...r, + ticketnumber: r.pinswg_DocumentIds?.ticketnumber || null, + publishtoweb: r.pinswg_DocumentIds?.pinswg_publishtoweb || null + })); - var dataStr; - - _.has(data, "@odata.nextLink") == true && - ((dataStr = JSON.stringify(data["@odata.nextLink"])), - (data["@odata.nextLink"] = dataStr.split("/v8.2/")[1])); - return respondSuccess(res, data); - }) - .catch((error) => { - consoleLogger(error); - return respondError(res, { - status: 400, - code: "ADMIN_LATEST_DOCS_FETCH_FAILED", - message: "Failed to fetch latest documents" - }); + data.value = flattened; + data.value.forEach(function (element) { + element.pinswg_hashlink = encryptDocReference( + element.pinswg_isharedocumentreference + ); }); - return apiResponse; + let dataStr; + + _.has(data, "@odata.nextLink") == true && + ((dataStr = JSON.stringify(data["@odata.nextLink"])), + (data["@odata.nextLink"] = dataStr.split("/v8.2/")[1])); + return respondSuccess(res, data); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "ADMIN_LATEST_DOCS_FETCH_FAILED", + message: "Failed to fetch latest documents" + }); + } } diff --git a/pages/api/admin/getnewappeals_api.js b/pages/api/admin/getnewappeals_api.js index 595ed467..01c321eb 100644 --- a/pages/api/admin/getnewappeals_api.js +++ b/pages/api/admin/getnewappeals_api.js @@ -16,7 +16,6 @@ */ import axios from "axios"; -import CryptoJS from "crypto-js"; import _ from "lodash"; import { azureHeadersPagedCustom } from "../../../actions/core/headers"; import { getToken } from "../../../actions/core/token"; @@ -29,15 +28,14 @@ const WEBAPI_URL = "https://dev-pedw-ns.servicebus.windows.net/dev-pedw-hc/"; export default async function ApiProxy(req, res) { - var searchString = req.query.searchString; - var pageNumber = req.query.pageNumber || 1; - var token = await getToken(); + const pageNumber = req.query.pageNumber || 1; + const token = await getToken(); - var orderby = req.query.orderby || "createdon"; - var fieldSort = req.query.fieldSort || "desc"; - var showNumberOfRecords = req.query.showNumberOfRecords || 10; + const orderby = req.query.orderby || "createdon"; + const fieldSort = req.query.fieldSort || "desc"; + const showNumberOfRecords = req.query.showNumberOfRecords || 10; - var queryUrl = + const queryUrl = "incidents?$select=caseorigincode,pinswg_publishtoweb,modifiedon,description,numberofchildincidents,_accountid_value,_customerid_value,_pinswg_associatedlpa_value,_ownerid_value,pinswg_appealcasetype,statuscode,ticketnumber,title, _primarycontactid_value,pinswg_lpareference,pinswg_appellantagent,pinswg_appellantfirstname,pinswg_appellantlastname&$expand=primarycontactid($select=fullname)&$orderby=" + orderby + " " + @@ -55,26 +53,23 @@ export default async function ApiProxy(req, res) { "\n==========================================\n" ); - var apiResponse = axios - .get( + try { + const { data } = await axios.get( WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), azureHeadersPagedCustom(token.access_token, showNumberOfRecords) - ) - .then(({ data }) => { - var dataStr; - _.has(data, "@odata.nextLink") == true && - ((dataStr = JSON.stringify(data["@odata.nextLink"])), - (data["@odata.nextLink"] = dataStr.split("/v8.2/")[1])); - return respondSuccess(res, data); - }) - .catch((error) => { - consoleLogger(error); - return respondError(res, { - status: 400, - code: "ADMIN_NEW_APPEALS_FETCH_FAILED", - message: "Failed to fetch new appeals" - }); - }); + ); - return apiResponse; + let dataStr; + _.has(data, "@odata.nextLink") == true && + ((dataStr = JSON.stringify(data["@odata.nextLink"])), + (data["@odata.nextLink"] = dataStr.split("/v8.2/")[1])); + return respondSuccess(res, data); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "ADMIN_NEW_APPEALS_FETCH_FAILED", + message: "Failed to fetch new appeals" + }); + } } diff --git a/pages/api/email/getcaseref.js b/pages/api/email/getcaseref.js index ddc59198..52e07578 100644 --- a/pages/api/email/getcaseref.js +++ b/pages/api/email/getcaseref.js @@ -22,27 +22,25 @@ function flattenWatchlistEntry(entry) { } export default async function ApiProxy(req, res) { - var token = await getToken(); + const token = await getToken(); - var queryUrl = + const queryUrl = "pinswg_watchlists?$count=true&$filter=pinswg_emailnotifications ne null&$expand=pinswg_Contact($select=contactid,emailaddress1)&$select=pinswg_emailnotifications,_pinswg_watchedcase_value"; - return axios - .get( + try { + const { data } = await axios.get( WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), azureHeaders(token.access_token) - ) - .then(({ data }) => { - const flattenedResults = data.value.map(flattenWatchlistEntry); + ); + const flattenedResults = data.value.map(flattenWatchlistEntry); - return respondSuccess(res, flattenedResults); - }) - .catch((error) => { - consoleLogger(error); - return respondError(res, { - status: 400, - code: "CASE_REF_FETCH_FAILED", - message: "Failed to fetch case references" - }); + return respondSuccess(res, flattenedResults); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "CASE_REF_FETCH_FAILED", + message: "Failed to fetch case references" }); + } } diff --git a/pages/api/email/getmailinglist.js b/pages/api/email/getmailinglist.js index 3a980c57..6c368fb0 100644 --- a/pages/api/email/getmailinglist.js +++ b/pages/api/email/getmailinglist.js @@ -22,27 +22,25 @@ function flattenWatchlistEntry(entry) { } export default async function ApiProxy(req, res) { - var token = await getToken(); + const token = await getToken(); - var queryUrl = + const queryUrl = "pinswg_watchlists?$count=true&$filter=pinswg_emailnotifications ne null&$expand=pinswg_Contact($select=contactid,emailaddress1)&$select=pinswg_emailnotifications,_pinswg_watchedcase_value"; - return axios - .get( + try { + const { data } = await axios.get( WEBAPI_URL + queryUrl + hashAPIPath(queryUrl), azureHeaders(token.access_token) - ) - .then(({ data }) => { - const flattenedResults = data.value.map(flattenWatchlistEntry); + ); + const flattenedResults = data.value.map(flattenWatchlistEntry); - return respondSuccess(res, flattenedResults); - }) - .catch((error) => { - consoleLogger(error); - return respondError(res, { - status: 400, - code: "MAILING_LIST_FETCH_FAILED", - message: "Failed to fetch mailing list" - }); + return respondSuccess(res, flattenedResults); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "MAILING_LIST_FETCH_FAILED", + message: "Failed to fetch mailing list" }); + } } diff --git a/pages/api/email/notify.js b/pages/api/email/notify.js index edf8a7d0..a03b1aa7 100644 --- a/pages/api/email/notify.js +++ b/pages/api/email/notify.js @@ -31,7 +31,7 @@ import { getPreferredLanguage } from "../../../actions/services/accountService"; import { respondError, respondSuccess } from "../middleware/apiResponse"; export default async function ApiProxy(req, res) { - var data = req.body; + const data = req.body; const emailAddress = sanitizeString(data?.emailAddress); if (!isNonEmptyString(emailAddress)) { @@ -58,7 +58,7 @@ export default async function ApiProxy(req, res) { } } - var NotifyClient = require("notifications-node-client").NotifyClient; + const NotifyClient = require("notifications-node-client").NotifyClient; const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY); //const emailReplyToId = process.env.EMAIL_REPLY_TO_ID; @@ -68,20 +68,19 @@ export default async function ApiProxy(req, res) { payload: redactSensitive(data) }); - notifyClient - .sendEmail(data.templateId, data.emailAddress, { + try { + await notifyClient.sendEmail(data.templateId, data.emailAddress, { personalisation: data.personalisation, reference: data.reference - }) - .then(() => { - return respondSuccess(res, data); - }) - .catch((error) => { - consoleLogger(error); - return respondError(res, { - status: 400, - code: "EMAIL_NOTIFY_FAILED", - message: "Failed to send notify email" - }); }); + + return respondSuccess(res, data); + } catch (error) { + consoleLogger(error); + return respondError(res, { + status: 400, + code: "EMAIL_NOTIFY_FAILED", + message: "Failed to send notify email" + }); + } } diff --git a/pages/api/endpoint/getportallogin_api.js b/pages/api/endpoint/getportallogin_api.js index e1adb714..c3f52aae 100644 --- a/pages/api/endpoint/getportallogin_api.js +++ b/pages/api/endpoint/getportallogin_api.js @@ -49,8 +49,16 @@ export default async function ApiProxy(req, res) { const checkquerypath = "/api/endpoint/getportallogin_api?emailAddress=" + emailAddress; + const encodedCheckquerypath = + "/api/endpoint/getportallogin_api?emailAddress=" + + encodeURIComponent(emailAddress); - if (hashAPIPath(checkquerypath) != "&hash=" + checkHash) { + const expectedHashCandidates = [ + hashAPIPath(checkquerypath), + hashAPIPath(encodedCheckquerypath) + ]; + + if (!expectedHashCandidates.includes("&hash=" + checkHash)) { return respondError(res, { status: 400, code: "INVALID_HASH", diff --git a/tests/phase21/endpoint-handler-contract.test.cjs b/tests/phase21/endpoint-handler-contract.test.cjs index bb523855..8e165eba 100644 --- a/tests/phase21/endpoint-handler-contract.test.cjs +++ b/tests/phase21/endpoint-handler-contract.test.cjs @@ -323,6 +323,93 @@ test("getportallogin catch path returns PORTAL_LOGIN_FETCH_FAILED", async () => ); }); +test("getportallogin accepts encoded email hash variant", async () => { + const mod = loadModule("pages/api/endpoint/getportallogin_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: (input) => + input && input.includes("emailAddress=user%2Btest%40local") + ? "&hash=expected" + : "&hash=other", + azureHeadersPaged: () => ({}), + axios: { + get: async () => ({ data: { value: [{ contactid: "c1" }] } }) + }, + consoleLogger: () => {} + }); + + const req = { + query: { emailAddress: "user+test@local", hash: "expected" } + }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 200); + assert.deepStrictEqual(JSON.parse(JSON.stringify(res.state.jsonBody)), { + value: [{ contactid: "c1" }] + }); +}); + +test("getnewappeals catch path returns ADMIN_NEW_APPEALS_FETCH_FAILED", async () => { + const mod = loadModule("pages/api/admin/getnewappeals_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: () => "&hash=expected", + azureHeadersPagedCustom: () => ({}), + axios: { + get: async () => { + throw new Error("admin new appeals failed"); + } + }, + consoleLogger: () => {}, + _: { has: () => false } + }); + + const req = { query: {} }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "ADMIN_NEW_APPEALS_FETCH_FAILED" + ); +}); + +test("getlatestdocuments catch path returns ADMIN_LATEST_DOCS_FETCH_FAILED", async () => { + const mod = loadModule("pages/api/admin/getlatestdocuments_api.js", { + respondError: respondErrorMock, + respondSuccess: respondSuccessMock, + getToken: async () => ({ access_token: "token" }), + hashAPIPath: () => "&hash=expected", + azureHeadersPagedCustom: () => ({}), + axios: { + get: async () => { + throw new Error("latest docs failed"); + } + }, + consoleLogger: () => {}, + process: { env: { HASHKEY: "abc" } }, + CryptoJS: { + HmacSHA256: () => ({ toString: () => "hash" }), + enc: { Hex: { parse: () => "parsed" } } + }, + _: { has: () => false } + }); + + const req = { query: {} }; + const res = createRes(); + await mod.default(req, res); + + assert.strictEqual(res.state.statusCode, 400); + assert.strictEqual( + res.state.jsonBody.error.code, + "ADMIN_LATEST_DOCS_FETCH_FAILED" + ); +}); + test("getportalloginproxy catch path returns PORTAL_LOGIN_PROXY_FETCH_FAILED", async () => { const mod = loadModule("pages/api/endpoint/getportalloginproxy_api.js", { respondError: respondErrorMock,