diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ee5ab9687f..690609e904 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -36,7 +36,8 @@ "streetsidesoftware.code-spell-checker", "timonwong.shellcheck", "mkhl.direnv", - "github.vscode-github-actions" + "github.vscode-github-actions", + "sonarsource.sonarlint-vscode" ], "settings": { "python.defaultInterpreterPath": "/workspaces/eps-prescription-status-update-api/.venv/bin/python", diff --git a/packages/gsul/src/dynamoDBclient.ts b/packages/gsul/src/dynamoDBclient.ts index 7d78fe291b..1aa871bd39 100644 --- a/packages/gsul/src/dynamoDBclient.ts +++ b/packages/gsul/src/dynamoDBclient.ts @@ -39,8 +39,11 @@ export async function getItemsUpdatesForPrescription( return items.map((singleUpdate) => ({ itemId: String(singleUpdate.LineItemID), latestStatus: String(singleUpdate.Status), - isTerminalState: String(singleUpdate.TerminalStatus) === "completed", - lastUpdateDateTime: String(singleUpdate.LastModified) + isTerminalState: String(singleUpdate.TerminalStatus).toLowerCase() === "completed", + lastUpdateDateTime: String(singleUpdate.LastModified), + ...(singleUpdate.PostDatedLastModifiedSetAt && { + postDatedLastModifiedSetAt: String(singleUpdate.PostDatedLastModifiedSetAt) + }) })) } diff --git a/packages/gsul/src/getStatusUpdates.ts b/packages/gsul/src/getStatusUpdates.ts index b2636ad8c3..abada87188 100644 --- a/packages/gsul/src/getStatusUpdates.ts +++ b/packages/gsul/src/getStatusUpdates.ts @@ -27,7 +27,7 @@ const lambdaHandler = async (event: requestType): Promise => { // this is an async map so it returns an array of promises const itemResults = event.prescriptions.map(async (prescription) => { const queryResult = await getItemsUpdatesForPrescription(prescription.prescriptionID, prescription.odsCode, logger) - return buildResult(prescription, queryResult) + return filterOutFutureReduceToLatestUpdates(prescription, queryResult) }) // wait for all the promises to complete @@ -40,21 +40,56 @@ const lambdaHandler = async (event: requestType): Promise => { return response } -export const buildResult = ( +export const filterOutFutureReduceToLatestUpdates = ( inputPrescription: inputPrescriptionType, - items: Array + items: Array, + currentTime: number = Date.now() // injectable for testing ): outputPrescriptionType => { - // get unique item ids with the latest update based on lastUpdateDateTime - const uniqueItems: Array = Object.values( - items.reduce(function (r, e) { - if (!r[e.itemId] || Date.parse(e.lastUpdateDateTime) > Date.parse(r[e.itemId].lastUpdateDateTime)) r[e.itemId] = e - return r - }, {}) - ) + + // filter out items with future lastUpdateDateTime + const validTimeUpdates = items.filter(item => { + const updateTime = Date.parse(item.lastUpdateDateTime) + return updateTime <= currentTime + }) + + // group by itemId and separate post-dated from regular updates + const itemGroups: Record = {} + + validTimeUpdates.forEach(item => { + if (!itemGroups[item.itemId]) { + itemGroups[item.itemId] = {regular: null, postDated: null} + } + const group = itemGroups[item.itemId] + + if (item.postDatedLastModifiedSetAt && !group.postDated) { // this is a post-dated update + group.postDated = item + } else if (item.postDatedLastModifiedSetAt && group.postDated) { // also a post-dated update + const existingTime = Date.parse(group.postDated.postDatedLastModifiedSetAt) + const newTime = Date.parse(item.postDatedLastModifiedSetAt) + if (newTime > existingTime) { + group.postDated = item + } + } else if (!group.regular) { // this is a regular update + group.regular = item + } else if (group.regular) { // also a regular update + const existingTime = Date.parse(group.regular.lastUpdateDateTime) + const newTime = Date.parse(item.lastUpdateDateTime) + if (newTime > existingTime) { + group.regular = item + } + } + }) + + // flatten both regular and post-dated updates into single array + const uniqueItems: Array = [] + Object.values(itemGroups).forEach(group => { + if (group.regular) uniqueItems.push(group.regular) + if (group.postDated) uniqueItems.push(group.postDated) + }) const result: outputPrescriptionType = { prescriptionID: inputPrescription.prescriptionID, - onboarded: items.length > 0, + onboarded: items.length > 0, // consider onboarded even if all updates were post-dated items: uniqueItems } return result diff --git a/packages/gsul/src/schema/response.ts b/packages/gsul/src/schema/response.ts index cd496e762f..5aeafb4252 100644 --- a/packages/gsul/src/schema/response.ts +++ b/packages/gsul/src/schema/response.ts @@ -15,6 +15,9 @@ const itemSchema = { }, lastUpdateDateTime: { type: "string" + }, + postDatedLastModifiedSetAt: { + type: "string" } } } as const diff --git a/packages/gsul/tests/testBuildResult.test.ts b/packages/gsul/tests/testBuildResult.test.ts index 0847bef88c..918b817bf9 100644 --- a/packages/gsul/tests/testBuildResult.test.ts +++ b/packages/gsul/tests/testBuildResult.test.ts @@ -1,4 +1,4 @@ -import {buildResult} from "../src/getStatusUpdates" +import {filterOutFutureReduceToLatestUpdates} from "../src/getStatusUpdates" import {inputPrescriptionType} from "../src/schema/request" import {outputPrescriptionType, itemType} from "../src/schema/response" @@ -8,6 +8,8 @@ type scenariosType = { queryResults: Array expectedResult: outputPrescriptionType } +const now = new Date() +const futureDateTime = new Date(now.valueOf() + (24 * 60 * 60 * 1000)).toISOString() const scenarios: Array = [ { scenarioDescription: "should return correct data when a matched prescription found", @@ -83,7 +85,7 @@ const scenarios: Array = [ } }, { - scenarioDescription: "should return correct data for multiple items", + scenarioDescription: "should return latest item for multiple updates for each of multiple statuses", inputPrescriptions: { prescriptionID: "abc", odsCode: "123" @@ -97,9 +99,9 @@ const scenarios: Array = [ }, { itemId: "item_1", - latestStatus: "latest_item_1_status", + latestStatus: "item_1_status", isTerminalState: true, - lastUpdateDateTime: "1972-01-01T00:00:00Z" + lastUpdateDateTime: "1972-01-01T00:00:00Z" // newer update for item_1 }, { itemId: "item_2", @@ -109,9 +111,9 @@ const scenarios: Array = [ }, { itemId: "item_2", - latestStatus: "early_item_2_status", + latestStatus: "item_2_status", isTerminalState: true, - lastUpdateDateTime: "1970-01-01T00:00:00Z" + lastUpdateDateTime: "1970-01-01T00:00:00Z" // older update for item_2 } ], expectedResult: { @@ -120,7 +122,7 @@ const scenarios: Array = [ items: [ { itemId: "item_1", - latestStatus: "latest_item_1_status", + latestStatus: "item_1_status", isTerminalState: true, lastUpdateDateTime: "1972-01-01T00:00:00Z" }, @@ -132,11 +134,137 @@ const scenarios: Array = [ } ] } + }, + { + scenarioDescription: "should exclude item when post-dated update hasn't matured", + inputPrescriptions: { + prescriptionID: "abc", + odsCode: "123" + }, + queryResults: [ + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: "2030-01-01T00:00:00Z", // Future, no fallback + postDatedLastModifiedSetAt:"1972-01-01T00:00:00Z" + } + ], + expectedResult: { + prescriptionID: "abc", + onboarded: true, + items: [] + } + }, + { + scenarioDescription: "should use latest post-dated update when multiple have matured", + inputPrescriptions: { + prescriptionID: "abc", + odsCode: "123" + }, + queryResults: [ + { + itemId: "item_1", + latestStatus: "With pharmacy", + isTerminalState: false, + lastUpdateDateTime: "1970-01-01T00:00:00Z" + }, + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: "1970-01-02T00:00:00Z", + postDatedLastModifiedSetAt: "1970-01-01T00:00:00Z" // first RTC: post-dated and matured + }, + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: futureDateTime, + postDatedLastModifiedSetAt: "1970-01-02T00:00:00Z" // second RTC: post-dated and yet to mature + }, + { + itemId: "item_1", + latestStatus: "With pharmacy", + isTerminalState: false, + lastUpdateDateTime: "1970-01-03T00:00:00Z" // Back to 'With pharmacy' + }, + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: "1970-01-04T00:00:00Z", + postDatedLastModifiedSetAt: "1970-01-03T00:00:00Z" // third RTC: post-dated and matured + } + ], + expectedResult: { + prescriptionID: "abc", + onboarded: true, + items: [ + { + itemId: "item_1", + latestStatus: "With pharmacy", + isTerminalState: false, + lastUpdateDateTime: "1970-01-03T00:00:00Z" + }, + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: "1970-01-04T00:00:00Z", + postDatedLastModifiedSetAt: "1970-01-03T00:00:00Z" + } + ] + } + }, + { + scenarioDescription: "should return an item when it _has_ matured even though the post-dated time is in the future", + inputPrescriptions: { + prescriptionID: "abc", + odsCode: "123" + }, + queryResults: [ + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: "1970-01-01T00:00:00Z", + postDatedLastModifiedSetAt: futureDateTime + } + ], + expectedResult: { + prescriptionID: "abc", + onboarded: true, + items: [ + { + itemId: "item_1", + latestStatus: "Ready to collect", + isTerminalState: false, + lastUpdateDateTime: "1970-01-01T00:00:00Z", + postDatedLastModifiedSetAt: futureDateTime + } + ] + } + }, + { + scenarioDescription: "should return no items when empty item status are found", + inputPrescriptions: { + prescriptionID: "abc", + odsCode: "123" + }, + queryResults: [], + expectedResult: { + prescriptionID: "abc", + onboarded: false, + items: [] + } } ] describe("Unit tests for buildResults", () => { it.each(scenarios)("$scenarioDescription", ({inputPrescriptions, queryResults, expectedResult}) => { - const result = buildResult(inputPrescriptions, queryResults) + // Use a fixed time of 2000-01-01 for tests (946684800000 ms since epoch) + const fixedCurrentTime = new Date("2000-01-01T00:00:00Z").getTime() + const result = filterOutFutureReduceToLatestUpdates(inputPrescriptions, queryResults, fixedCurrentTime) expect(result).toMatchObject(expectedResult) }) })