Skip to content

Commit 52b0878

Browse files
committed
Update logic to actually compute the time until the prescription matures, and pass it to the SQS
1 parent 08e3065 commit 52b0878

7 files changed

Lines changed: 302 additions & 13 deletions

File tree

packages/postDatedLambda/src/businessLogic.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,20 @@ export function processMessage(
128128

129129
return PostDatedProcessingResult.MATURED
130130
}
131+
132+
/**
133+
* returns time in seconds until maturity, or undefined if cannot be determined
134+
*/
135+
export function computeTimeUntilMaturity(
136+
data: PostDatedSQSMessageWithExistingRecords
137+
): number | undefined {
138+
const prescriptionRecord = getMostRecentRecord(data.existingRecords)
139+
if (!prescriptionRecord.PostDatedLastModifiedSetAt) {
140+
return undefined
141+
}
142+
143+
const lastModified = new Date(prescriptionRecord.LastModified)
144+
const currentTime = new Date()
145+
146+
return lastModified.getTime() - currentTime.getTime()
147+
}

packages/postDatedLambda/src/orchestration.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {Logger} from "@aws-lambda-powertools/logger"
22

3-
import {processMessage} from "./businessLogic"
3+
import {computeTimeUntilMaturity, processMessage} from "./businessLogic"
44
import {enrichMessagesWithExistingRecords} from "./databaseClient"
55
import {receivePostDatedSQSMessages, reportQueueStatus, handleProcessedMessages} from "./sqs"
66
import {BatchProcessingResult, PostDatedProcessingResult, PostDatedSQSMessage} from "./types"
@@ -34,6 +34,8 @@ export async function processMessages(
3434
if (action === PostDatedProcessingResult.MATURED) {
3535
maturedPrescriptionUpdates.push(message)
3636
} else if (action === PostDatedProcessingResult.IMMATURE) {
37+
// Set visibility timeout to time until maturity, or default if calculation fails
38+
message.visibilityTimeoutSeconds = computeTimeUntilMaturity(message)
3739
immaturePrescriptionUpdates.push(message)
3840
} else {
3941
ignoredPrescriptionUpdates.push(message)

packages/postDatedLambda/src/sqs.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {BatchProcessingResult, PostDatedSQSMessage} from "./types"
1515

1616
const sqs = new SQSClient({region: process.env.AWS_REGION})
1717

18+
const DEFAULT_VISIBILITY_TIMEOUT_SECONDS = 300 // 5 minutes
19+
1820
// Note that a lot of the code to send an SQS message is copied from the updatePrescriptionStatus lambda,
1921
// and I've NOT moved the code into a shared location for the two.
2022
// This is because I don't want to alter the updatePrescriptionStatus lambda in that way
@@ -318,7 +320,7 @@ export async function removeSQSMessages(
318320
*/
319321
export async function returnMessagesToQueue(
320322
logger: Logger,
321-
messages: Array<Message>
323+
messages: Array<PostDatedSQSMessage>
322324
): Promise<void> {
323325
if (messages.length === 0) {
324326
// exit early so we don't send a ChangeMessageVisibilityBatch with no entries
@@ -328,13 +330,10 @@ export async function returnMessagesToQueue(
328330

329331
const sqsUrl = getPostDatedQueueUrl(logger)
330332

331-
// TODO: Each message needs to have an appropriate visibility timeout based on when it is due to be retried.
332-
// For now, use a fixed 5 minute timeout for all messages.
333-
const visibilityTimeoutSeconds = 300
334333
const entries = messages.map((m) => ({
335334
Id: m.MessageId!,
336335
ReceiptHandle: m.ReceiptHandle!,
337-
VisibilityTimeout: visibilityTimeoutSeconds
336+
VisibilityTimeout: m.visibilityTimeoutSeconds || DEFAULT_VISIBILITY_TIMEOUT_SECONDS
338337
}))
339338

340339
logger.info(

packages/postDatedLambda/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {NotifyDataItem, PSUDataItem} from "@psu-common/commonTypes"
77
*/
88
export interface PostDatedSQSMessage extends Message {
99
prescriptionData: NotifyDataItem
10+
visibilityTimeoutSeconds?: number
1011
}
1112

1213
/**
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import {
2+
expect,
3+
describe,
4+
it,
5+
afterEach,
6+
jest
7+
} from "@jest/globals"
8+
9+
import {Logger} from "@aws-lambda-powertools/logger"
10+
11+
import {PSUDataItem} from "@psu-common/commonTypes"
12+
13+
import {PostDatedProcessingResult, PostDatedSQSMessageWithExistingRecords} from "../src/types"
14+
import {createMockPostModifiedDataItem} from "./testUtils"
15+
16+
type BusinessLogicModule = typeof import("../src/businessLogic")
17+
18+
const ORIGINAL_ENV = {...process.env}
19+
20+
async function loadBusinessLogic(
21+
envOverrides = {}
22+
): Promise<BusinessLogicModule> {
23+
// Makes sure that the environment is set before import each time
24+
jest.resetModules()
25+
process.env = {...ORIGINAL_ENV, ...envOverrides}
26+
return import("../src/businessLogic")
27+
}
28+
29+
function createPSURecord(overrides: Partial<PSUDataItem> = {}): PSUDataItem {
30+
const baseRecord: PSUDataItem = {
31+
LastModified: new Date("2026-01-01T00:00:00.000Z").toISOString(),
32+
LineItemID: "line-item",
33+
PatientNHSNumber: "0123456789",
34+
PharmacyODSCode: "ABC123",
35+
PrescriptionID: "RX123",
36+
RepeatNo: 0,
37+
RequestID: "req-123",
38+
Status: "ready to collect",
39+
TaskID: "task-123",
40+
TerminalStatus: "terminal",
41+
ApplicationName: "post-dated-tests",
42+
ExpiryTime: 0,
43+
PostDatedLastModifiedSetAt: "2026-01-01T00:00:00.000Z"
44+
}
45+
46+
return {
47+
...baseRecord,
48+
...overrides
49+
}
50+
}
51+
52+
function createMessage(
53+
overrides: Partial<PostDatedSQSMessageWithExistingRecords> = {}
54+
): PostDatedSQSMessageWithExistingRecords {
55+
const prescData = createMockPostModifiedDataItem({})
56+
const baseMessage: PostDatedSQSMessageWithExistingRecords = {
57+
MessageId: "msg-123",
58+
ReceiptHandle: "receipt-123",
59+
Body: JSON.stringify(prescData),
60+
Attributes: {
61+
62+
},
63+
prescriptionData: prescData,
64+
// In theory, this should contain the record corresponding to prescData, but for testing purposes it's fine
65+
existingRecords: [createPSURecord()]
66+
}
67+
68+
return {
69+
...baseMessage,
70+
...overrides,
71+
prescriptionData: overrides.prescriptionData ?? baseMessage.prescriptionData,
72+
existingRecords: overrides.existingRecords ?? baseMessage.existingRecords
73+
}
74+
}
75+
76+
afterEach(() => {
77+
process.env = {...ORIGINAL_ENV}
78+
jest.useRealTimers()
79+
})
80+
81+
describe("businessLogic", () => {
82+
describe("getMostRecentRecord", () => {
83+
it("should return the record with the latest timestamp, when all records are post-dated", async () => {
84+
const {getMostRecentRecord} = await loadBusinessLogic()
85+
const records = [
86+
createPSURecord({
87+
LineItemID: "line-old",
88+
PostDatedLastModifiedSetAt: "2026-01-01T00:00:00.000Z",
89+
LastModified: "2026-01-02T00:00:00.000Z" // The time it's scheduled to mature
90+
}),
91+
createPSURecord({
92+
LineItemID: "line-new",
93+
PostDatedLastModifiedSetAt: "2026-01-01T12:00:00.000Z", // submitted 12 hours later
94+
LastModified: "2026-01-01T18:00:00.000Z" // but last modified is earlier
95+
})
96+
]
97+
98+
const result = getMostRecentRecord(records)
99+
100+
expect(result.LineItemID).toBe("line-new")
101+
})
102+
103+
it("Should return the latest record when only one record is post-dated", async () => {
104+
const {getMostRecentRecord} = await loadBusinessLogic()
105+
const records = [
106+
createPSURecord({ // post-dated record submitted first
107+
LineItemID: "line-old",
108+
PostDatedLastModifiedSetAt: "2026-01-01T00:00:00.000Z",
109+
LastModified: "2026-01-15T00:00:00.000Z" // The time it's scheduled to mature
110+
}),
111+
createPSURecord({ // contemporary record submitted second
112+
LineItemID: "line-new",
113+
PostDatedLastModifiedSetAt: undefined,
114+
LastModified: "2026-01-02T00:00:00.000Z" // The time the prescription was actually ready to collect
115+
})
116+
]
117+
118+
const result = getMostRecentRecord(records)
119+
120+
expect(result.LineItemID).toBe("line-new")
121+
})
122+
123+
it("should return the latest record when no records are post-dated", async () => {
124+
const {getMostRecentRecord} = await loadBusinessLogic()
125+
const records = [
126+
createPSURecord({
127+
LineItemID: "line-old",
128+
PostDatedLastModifiedSetAt: undefined,
129+
LastModified: "2026-01-01T00:00:00.000Z"
130+
}),
131+
createPSURecord({
132+
LineItemID: "line-new",
133+
PostDatedLastModifiedSetAt: undefined,
134+
LastModified: "2026-02-01T00:00:00.000Z"
135+
})
136+
]
137+
138+
const result = getMostRecentRecord(records)
139+
140+
expect(result.LineItemID).toBe("line-new")
141+
})
142+
})
143+
144+
describe("processMessage", () => {
145+
it("should return the override value when override mode is enabled", async () => {
146+
const {processMessage} = await loadBusinessLogic({
147+
POST_DATED_OVERRIDE: "true",
148+
POST_DATED_OVERRIDE_VALUE: "immature"
149+
})
150+
const logger = new Logger({serviceName: "post-dated-tests"})
151+
152+
const result = processMessage(logger, createMessage())
153+
154+
expect(result).toBe(PostDatedProcessingResult.IMMATURE)
155+
})
156+
157+
it("should ignore messages that have no existing records", async () => {
158+
const {processMessage} = await loadBusinessLogic()
159+
const logger = new Logger({serviceName: "post-dated-tests"})
160+
const message = createMessage({existingRecords: []})
161+
162+
const result = processMessage(logger, message)
163+
164+
expect(result).toBe(PostDatedProcessingResult.IGNORE)
165+
})
166+
167+
it("should ignore messages when the most recent record is not post-dated", async () => {
168+
const {processMessage} = await loadBusinessLogic()
169+
const logger = new Logger({serviceName: "post-dated-tests"})
170+
const message = createMessage({
171+
existingRecords: [
172+
createPSURecord({
173+
LineItemID: "line-no-post-date",
174+
PostDatedLastModifiedSetAt: undefined
175+
})
176+
]
177+
})
178+
179+
const result = processMessage(logger, message)
180+
181+
expect(result).toBe(PostDatedProcessingResult.IGNORE)
182+
})
183+
184+
it("should ignore messages when the status is not notifiable", async () => {
185+
const {processMessage} = await loadBusinessLogic()
186+
const logger = new Logger({serviceName: "post-dated-tests"})
187+
const message = createMessage({
188+
existingRecords: [
189+
createPSURecord({
190+
Status: "dispensed",
191+
LineItemID: "line-not-notifiable"
192+
})
193+
]
194+
})
195+
196+
const result = processMessage(logger, message)
197+
198+
expect(result).toBe(PostDatedProcessingResult.IGNORE)
199+
})
200+
201+
it("should classify a message as immature when LastModified is in the future", async () => {
202+
jest.useFakeTimers()
203+
jest.setSystemTime(new Date("2026-01-01T12:00:00.000Z"))
204+
const {processMessage} = await loadBusinessLogic()
205+
const logger = new Logger({serviceName: "post-dated-tests"})
206+
const message = createMessage({
207+
existingRecords: [
208+
createPSURecord({
209+
LastModified: "2026-01-02T12:00:00.000Z",
210+
PostDatedLastModifiedSetAt: "2026-01-02T12:00:00.000Z",
211+
LineItemID: "line-future"
212+
})
213+
]
214+
})
215+
216+
const result = processMessage(logger, message)
217+
218+
expect(result).toBe(PostDatedProcessingResult.IMMATURE)
219+
})
220+
221+
it("should classify a message as matured when LastModified is in the past", async () => {
222+
jest.useFakeTimers()
223+
jest.setSystemTime(new Date("2026-01-03T12:00:00.000Z"))
224+
const {processMessage} = await loadBusinessLogic()
225+
const logger = new Logger({serviceName: "post-dated-tests"})
226+
const message = createMessage({
227+
existingRecords: [
228+
createPSURecord({
229+
LastModified: "2026-01-02T12:00:00.000Z",
230+
PostDatedLastModifiedSetAt: "2026-01-02T12:00:00.000Z",
231+
LineItemID: "line-past"
232+
})
233+
]
234+
})
235+
236+
const result = processMessage(logger, message)
237+
238+
expect(result).toBe(PostDatedProcessingResult.MATURED)
239+
})
240+
241+
it("should use the most recent record when determining maturity", async () => {
242+
jest.useFakeTimers()
243+
jest.setSystemTime(new Date("2026-01-05T12:00:00.000Z"))
244+
const {processMessage} = await loadBusinessLogic()
245+
const logger = new Logger({serviceName: "post-dated-tests"})
246+
const message = createMessage({
247+
existingRecords: [
248+
createPSURecord({
249+
LineItemID: "line-old",
250+
LastModified: "2026-01-01T12:00:00.000Z",
251+
PostDatedLastModifiedSetAt: "2026-01-01T12:00:00.000Z",
252+
Status: "ready to collect - partial"
253+
}),
254+
createPSURecord({
255+
LineItemID: "line-new",
256+
LastModified: "2026-01-06T12:00:00.000Z",
257+
PostDatedLastModifiedSetAt: "2026-01-06T12:00:00.000Z",
258+
Status: "ready to collect"
259+
})
260+
]
261+
})
262+
263+
const result = processMessage(logger, message)
264+
265+
expect(result).toBe(PostDatedProcessingResult.IMMATURE)
266+
})
267+
})
268+
})

packages/postDatedLambda/tests/testOrchestration.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77

88
// Mock the imports from local modules
99
const mockProcessMessage = jest.fn()
10+
const mockComputeTimeUntilMaturity = jest.fn().mockReturnValue(300)
1011
jest.unstable_mockModule("../src/businessLogic", () => {
1112
return {
12-
processMessage: mockProcessMessage
13+
processMessage: mockProcessMessage,
14+
computeTimeUntilMaturity: mockComputeTimeUntilMaturity
1315
}
1416
})
1517

packages/postDatedLambda/tests/testSqs.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ const {
3434
removeSQSMessages,
3535
returnMessagesToQueue,
3636
handleProcessedMessages,
37-
sendSQSMessagesToNotificationQueue
37+
forwardSQSMessagesToNotificationQueue: sendSQSMessagesToNotificationQueue
3838
} = await import("../src/sqs")
3939

4040
const ORIGINAL_ENV = {...process.env}
@@ -299,9 +299,9 @@ describe("sqs", () => {
299299
const testUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
300300
process.env.POST_DATED_PRESCRIPTIONS_SQS_QUEUE_URL = testUrl
301301

302-
const messagesToReturn = [
303-
{MessageId: "1", ReceiptHandle: "handle1"},
304-
{MessageId: "2", ReceiptHandle: "handle2"}
302+
const messagesToReturn: Array<PostDatedSQSMessage> = [
303+
{MessageId: "1", ReceiptHandle: "handle1", prescriptionData: createMockPostModifiedDataItem({})},
304+
{MessageId: "2", ReceiptHandle: "handle2", prescriptionData: createMockPostModifiedDataItem({})}
305305
]
306306

307307
// Mock SQS change visibility response
@@ -333,8 +333,8 @@ describe("sqs", () => {
333333
const testUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/test-queue"
334334
process.env.POST_DATED_PRESCRIPTIONS_SQS_QUEUE_URL = testUrl
335335

336-
const messagesToReturn = [
337-
{MessageId: "1", ReceiptHandle: "handle1"}
336+
const messagesToReturn: Array<PostDatedSQSMessage> = [
337+
{MessageId: "1", ReceiptHandle: "handle1", prescriptionData: createMockPostModifiedDataItem({})}
338338
]
339339

340340
// Mock SQS change visibility to throw an error

0 commit comments

Comments
 (0)