Skip to content

Commit 847cbf0

Browse files
authored
Chore: [AEA-0000] - Refactor notify lambda, and rearrange the secrets (#2141)
## Summary - Routine Change ### Details *MUST BE MERGED ONLY AFTER THIS PR: NHSDigital/electronic-prescription-service-account-resources#1474 Notify's callbacks do NOT sign their messages with the digital onboarding service name, as I'd thought. Rather, they sign with the ID of the app. i.e., they need `bd492cde-b67e-487b-97fd-44b7414c8e95`, not `EPS PSU notify callback int` for [this](https://onboarding.prod.api.platform.nhs.uk/MyApplications/ApplicationDetails?appId=bd492cde-b67e-487b-97fd-44b7414c8e95) app. However, we still need the name for the CIS2 token exchange stage, so that can't be just fixed in the config. Luckily, I have a spare secret (I must have known this at some point, and forgotten during the implementation), so I'm going to use that. I will redefine the secrets to be like this: - `secrets-PSU-Notify-Application-Name` - The application name, e.g. `EPS PSU notify callback int` - `secrets-PSU-Notify-Application-ID` - The Application ID, e.g. `bd492cde-b67e-487b-97fd-44b7414c8e95` - `secrets-PSU-Notify-API-Key` (no change) - Holds the API key of the DoS app - `secrets-PSU-Notify-PrivateKey` (no change) - Holds the private signing key While I'm here, I'm also going to do a bit of refactoring on the lambda code. Nothing should meaningfully change there, though.
1 parent 3eac61e commit 847cbf0

10 files changed

Lines changed: 220 additions & 144 deletions

File tree

SAMtemplates/functions/main.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ Resources:
474474
Variables:
475475
LOG_LEVEL: !Ref LogLevel
476476
TABLE_NAME: !Ref PrescriptionNotificationStatesTableName
477-
APP_NAME_SECRET: secrets-PSU-Notify-Application-Name
477+
APP_ID_SECRET: secrets-PSU-Notify-Application-ID
478478
API_KEY_SECRET: secrets-PSU-Notify-API-Key
479479
Metadata:
480480
BuildMethod: esbuild

packages/nhsNotifyLambda/src/nhsNotifyLambda.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ import {
1414
removeSQSMessages,
1515
reportQueueStatus,
1616
drainQueue,
17-
makeBatchNotifyRequest,
17+
handleNotifyRequests,
1818
NotifyDataItemMessage
1919
} from "./utils"
2020

2121
const logger = new Logger({serviceName: "nhsNotify"})
2222

23-
const MAX_QUEUE_RUNTIME = 14*60*1000 // 14 minutes, to avoid Lambda timeout issues (timeout is 15 minutes)
23+
const MAX_QUEUE_RUNTIME = 14 * 60 * 1000 // 14 minutes, to avoid Lambda timeout issues (timeout is 15 minutes)
2424

2525
/**
2626
* Process a single batch of SQS messages: filter, notify, persist, and clean up.
@@ -52,7 +52,7 @@ async function processBatch(
5252
// Send notifications
5353
let processed: Array<NotifyDataItemMessage> = []
5454
try {
55-
processed = await makeBatchNotifyRequest(logger, routingId, toProcess)
55+
processed = await handleNotifyRequests(logger, routingId, toProcess)
5656
} catch (err) {
5757
logger.error("Notification request failed, will retry", {error: err, toProcess})
5858
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {NotifyDataItemMessage} from "./types"
22
import {checkCooldownForUpdate, addPrescriptionMessagesToNotificationStateStore} from "./dynamo"
33
import {removeSQSMessages, drainQueue, reportQueueStatus} from "./sqs"
4-
import {makeBatchNotifyRequest} from "./notify"
4+
import {handleNotifyRequests, makeRealNotifyRequest} from "./notify"
55

66
export {
77
NotifyDataItemMessage,
@@ -10,5 +10,6 @@ export {
1010
removeSQSMessages,
1111
drainQueue,
1212
reportQueueStatus,
13-
makeBatchNotifyRequest
13+
handleNotifyRequests,
14+
makeRealNotifyRequest
1415
}

packages/nhsNotifyLambda/src/utils/notify.ts

Lines changed: 144 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -34,32 +34,57 @@ function estimateSize(obj: unknown) {
3434
}
3535

3636
/**
37-
* Returns the original data, updated with the status returned by NHS notify.
38-
* Does not return data for messages that failed to send.
39-
*
40-
* @param logger AWS logging object
41-
* @param routingPlanId The Notify routing plan ID with which to process the data
42-
* @param data The details for the notification
37+
* Create and configure an axios instance for Notify
38+
* @param logger
39+
* @param notifyBaseUrl - The base URL for the Notify API. DOES NOT include /comms, or anything after that.
40+
* @param bearerToken - The CIS2 OAuth bearer token to use for authentication
41+
* @returns
4342
*/
44-
export async function makeBatchNotifyRequest(
43+
function setupAxios(logger: Logger, notifyBaseUrl: string, bearerToken: string): ReturnType<typeof axios.create> {
44+
45+
const axiosInstance = axios.create({
46+
baseURL: notifyBaseUrl + "/comms",
47+
headers: {
48+
Accept: "*/*",
49+
"Content-Type": "application/vnd.api+json",
50+
Authorization: `Bearer ${bearerToken}`
51+
}
52+
})
53+
54+
// Retry configuration for rate limiting
55+
const onAxiosRetry = (retryCount: number, error: unknown) => {
56+
logger.warn(`Call to notify failed - retrying. Retry count ${retryCount}`, {error})
57+
}
58+
59+
// Axios-retry respects the `Retry-After` header OOTB
60+
axiosRetry(axiosInstance, {
61+
retries: 5,
62+
onRetry: onAxiosRetry
63+
})
64+
65+
return axiosInstance
66+
}
67+
68+
/**
69+
* Handles making requests to NHS Notify for a batch of messages.
70+
* Decides whether to make real requests or fake ones based on config.
71+
* @param logger
72+
* @param routingPlanId - The Notify routing plan ID with which to process the data
73+
* @param data - PSU SQS messages to process
74+
* @returns
75+
*/
76+
export async function handleNotifyRequests(
4577
logger: Logger,
4678
routingPlanId: string,
4779
data: Array<NotifyDataItemMessage>
4880
): Promise<Array<NotifyDataItemMessage>> {
49-
const {makeRealNotifyRequests, notifyApiBaseUrlRaw} = await loadConfig()
50-
51-
if (!notifyApiBaseUrlRaw) throw new Error("NOTIFY_API_BASE_URL is not defined in the environment variables!")
52-
53-
// Just to be safe, trim any whitespace. Also, secrets may be bytes, so make sure it's a string
54-
const BASE_URL = notifyApiBaseUrlRaw.trim()
5581

5682
// Early break for empty data
5783
if (data.length === 0) {
5884
return []
5985
}
6086

61-
// Shared between all messages in this batch
62-
const messageBatchReference = v4()
87+
const configPromise = loadConfig()
6388

6489
// Map the NotifyDataItems into the structure needed for notify
6590
const messages: Array<MessageBatchItem> = data.flatMap(item => {
@@ -73,10 +98,80 @@ export async function makeBatchNotifyRequest(
7398
messageReference: item.messageReference,
7499
recipient: {nhsNumber: item.PSUDataItem.PatientNHSNumber},
75100
originator: {odsCode: item.PSUDataItem.PharmacyODSCode},
76-
personalisation: {}
101+
personalisation: {},
102+
PSURequestId: item.PSUDataItem.RequestID
77103
}]
78104
})
79105

106+
// Check if we should make real requests
107+
const {makeRealNotifyRequestsFlag, notifyApiBaseUrlRaw} = await configPromise
108+
if (!makeRealNotifyRequestsFlag || !notifyApiBaseUrlRaw) return await makeFakeNotifyRequest(logger, data, messages)
109+
110+
if (!notifyApiBaseUrlRaw) throw new Error("NOTIFY_API_BASE_URL is not defined in the environment variables!")
111+
// Just to be safe, trim any whitespace. Also, secrets may be bytes, so make sure it's a string
112+
const notifyBaseUrl = notifyApiBaseUrlRaw.trim()
113+
114+
return await makeRealNotifyRequest(logger, routingPlanId, notifyBaseUrl, data, messages)
115+
}
116+
117+
/**
118+
* Simulates making requests to NHS Notify for a batch of messages.
119+
* Waits a short time, then returns "successful" responses for all messages.
120+
*/
121+
async function makeFakeNotifyRequest(
122+
logger: Logger,
123+
data: Array<NotifyDataItemMessage>,
124+
messages: Array<MessageBatchItem>
125+
): Promise<Array<NotifyDataItemMessage>> {
126+
127+
logger.info("Not doing real Notify requests. Simply waiting for some time and returning success on all messages")
128+
await new Promise(f => setTimeout(f, DUMMY_NOTIFY_DELAY_MS))
129+
130+
const messageStatus = "silent running"
131+
const messageBatchReference = v4()
132+
133+
logger.info("Requested notifications OK!", {
134+
messageBatchReference,
135+
messageReferences: messages.map(e => ({
136+
nhsNumber: e.recipient.nhsNumber,
137+
messageReference: e.messageReference,
138+
psuRequestId: data.find((el) => el.messageReference === e.messageReference)?.PSUDataItem.RequestID
139+
})),
140+
deliveryStatus: messageStatus // TODO: change splunk report query to messageStatus
141+
})
142+
143+
// Map each input item to a "successful" NotifyDataItemMessage
144+
return data.map(item => {
145+
return {
146+
...item,
147+
messageBatchReference,
148+
messageStatus,
149+
notifyMessageId: v4() // Create a dummy UUID
150+
}
151+
})
152+
}
153+
154+
/**
155+
* Makes real requests to NHS Notify for a batch of messages.
156+
* Handles splitting large batches into smaller ones as needed.
157+
*
158+
* @param logger - AWS logging object
159+
* @param routingPlanId - The Notify routing plan ID with which to process the data
160+
* @param data - The details for the notification
161+
*/
162+
export async function makeRealNotifyRequest(
163+
logger: Logger,
164+
routingPlanId: string,
165+
notifyBaseUrl: string,
166+
data: Array<NotifyDataItemMessage>,
167+
messages: Array<MessageBatchItem>,
168+
bearerToken?: string,
169+
axiosInstance?: ReturnType<typeof axios.create>
170+
): Promise<Array<NotifyDataItemMessage>> {
171+
172+
// Shared between all messages in this batch
173+
const messageBatchReference = v4()
174+
80175
const body: CreateMessageBatchRequest = {
81176
data: {
82177
type: "MessageBatch" as const,
@@ -88,80 +183,35 @@ export async function makeBatchNotifyRequest(
88183
}
89184
}
90185

186+
// Lazily get the bearer token and axios instance, so we only do it once even if we recurse
187+
if (!bearerToken) bearerToken = await tokenExchange(logger, notifyBaseUrl)
188+
if (!axiosInstance) axiosInstance = setupAxios(logger, notifyBaseUrl, bearerToken)
189+
91190
// Recursive split if too large
92-
if (data.length >= NOTIFY_REQUEST_MAX_ITEMS || estimateSize(body) > NOTIFY_REQUEST_MAX_BYTES) {
191+
if (messages.length >= NOTIFY_REQUEST_MAX_ITEMS || estimateSize(body) > NOTIFY_REQUEST_MAX_BYTES) {
93192
logger.info("Received a large payload - splitting in half and trying again",
94-
{messageCount: data.length, estimatedSize: estimateSize(body)}
193+
{messageCount: messages.length, estimatedSize: estimateSize(body)}
95194
)
96-
const mid = Math.floor(data.length / 2)
97-
const firstHalf = data.slice(0, mid)
98-
const secondHalf = data.slice(mid)
195+
const mid = Math.floor(messages.length / 2)
196+
const firstHalf = messages.slice(0, mid)
197+
const secondHalf = messages.slice(mid)
198+
99199
// send both halves in parallel
100200
const [res1, res2] = await Promise.all([
101-
makeBatchNotifyRequest(logger, routingPlanId, firstHalf),
102-
makeBatchNotifyRequest(logger, routingPlanId, secondHalf)
201+
makeRealNotifyRequest(logger, routingPlanId, notifyBaseUrl, data, firstHalf, bearerToken, axiosInstance),
202+
makeRealNotifyRequest(logger, routingPlanId, notifyBaseUrl, data, secondHalf, bearerToken, axiosInstance)
103203
])
104204
return [...res1, ...res2]
105205
}
106206

107-
if (!makeRealNotifyRequests) {
108-
logger.info("Not doing real Notify requests. Simply waiting for some time and returning success on all messages")
109-
await new Promise(f => setTimeout(f, DUMMY_NOTIFY_DELAY_MS))
110-
111-
const messageStatus = "silent running"
112-
113-
logger.info("Requested notifications OK!", {
114-
messageBatchReference,
115-
messageReferences: messages.map(e => ({
116-
nhsNumber: e.recipient.nhsNumber,
117-
messageReference: e.messageReference,
118-
psuRequestId: data.find((el) => el.messageReference === e.messageReference)?.PSUDataItem.RequestID
119-
})),
120-
deliveryStatus: messageStatus // TODO: change splunk report query to messageStatus
121-
})
122-
123-
// Map each input item to a "successful" NotifyDataItemMessage
124-
return data.map(item => {
125-
return {
126-
...item,
127-
messageBatchReference,
128-
messageStatus,
129-
notifyMessageId: v4() // Create a dummy UUID
130-
}
131-
})
132-
}
133-
134-
// This is actually going to hit notify, so get the bearer token
135-
const bearerToken = await tokenExchange(logger, BASE_URL)
136-
137-
logger.info("Making a request for notifications to NHS notify", {count: data.length, routingPlanId})
138-
139-
// Create an axios instance configured for Notify
140-
const axiosInstance = axios.create({
141-
baseURL: BASE_URL + "/comms",
142-
headers: {
143-
Accept: "*/*",
144-
"Content-Type": "application/vnd.api+json",
145-
Authorization: `Bearer ${bearerToken}`
146-
}
147-
})
148-
149-
// Retry configuration for rate limiting
150-
const onAxiosRetry = (retryCount: number, error: unknown) => {
151-
logger.warn(`Call to notify failed - retrying. Retry count ${retryCount}`, {error})
152-
}
153-
154-
// Axios-retry respects the `Retry-After` header
155-
axiosRetry(axiosInstance, {
156-
retries: 5,
157-
onRetry: onAxiosRetry
158-
})
207+
logger.info("Making a request for notifications to NHS notify", {count: messages.length, routingPlanId})
159208

160209
try {
161210
const resp = await axiosInstance.post<CreateMessageBatchResponse>("/v1/message-batches", body)
162211

212+
// From here is just logging stuff for reporting, and mapping the response back to the input data
213+
163214
if (resp.status === 201) {
164-
const returnedMessages = resp.data.data.attributes.messages
165215
logger.info("Requested notifications OK!", {
166216
messageBatchReference,
167217
messageReferences: messages.map(e => ({
@@ -172,20 +222,24 @@ export async function makeBatchNotifyRequest(
172222
deliveryStatus: "requested" // TODO: change splunk report query to messageStatus
173223
})
174224

175-
// Map each input item to a NotifyDataItemMessage, marking success and attaching the notify ID
176-
return data.map(item => {
177-
const match = returnedMessages.find(
178-
m => m.messageReference === item.messageReference
179-
)
180-
181-
// SUCCESS
182-
return {
183-
...item,
184-
messageBatchReference,
185-
messageStatus: match ? "requested" : "notify request failed",
186-
notifyMessageId: match?.id
187-
}
188-
})
225+
// Map each input item to a NotifyDataItemMessage, marking success and attaching the notify ID.
226+
// Only return items that belong to *this* batch (so we handle recursive splits correctly).
227+
const batchRefs = new Set(messages.map(m => m.messageReference))
228+
const returnedByRef = new Map(
229+
resp.data.data.attributes.messages.map(m => [m.messageReference, m])
230+
)
231+
232+
return data
233+
.filter(item => batchRefs.has(item.messageReference))
234+
.map(item => {
235+
const match = returnedByRef.get(item.messageReference)
236+
return {
237+
...item,
238+
messageBatchReference,
239+
messageStatus: match ? "requested" : "notify request failed",
240+
notifyMessageId: match?.id
241+
}
242+
})
189243

190244
} else {
191245
logger.error("Notify batch request failed", {

packages/nhsNotifyLambda/src/utils/ssm.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ import {SSMProvider} from "@aws-lambda-powertools/parameters/ssm"
33
const ssm = new SSMProvider()
44

55
export interface NotifyConfig {
6-
makeRealNotifyRequests: boolean
6+
makeRealNotifyRequestsFlag: boolean
77
notifyApiBaseUrlRaw: string
88
}
99

1010
export async function loadConfig(): Promise<{
11-
makeRealNotifyRequests: boolean,
11+
makeRealNotifyRequestsFlag: boolean,
1212
notifyApiBaseUrlRaw: string
1313
}> {
1414
const paramNames = {
@@ -24,7 +24,7 @@ export async function loadConfig(): Promise<{
2424
.trim()
2525

2626
return {
27-
makeRealNotifyRequests: realNotifyParam === "true",
27+
makeRealNotifyRequestsFlag: realNotifyParam === "true",
2828
notifyApiBaseUrlRaw: all[process.env.NOTIFY_API_BASE_URL_PARAM!] as string
2929
}
3030
}

packages/nhsNotifyLambda/tests/testNhsNotifyLambda.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jest.unstable_mockModule(
3333
addPrescriptionMessagesToNotificationStateStore: mockAddPrescriptionMessagesToNotificationStateStore,
3434
removeSQSMessages: mockRemoveSQSMessages,
3535
checkCooldownForUpdate: mockCheckCooldownForUpdate,
36-
makeBatchNotifyRequest: mockMakeBatchNotifyRequest
36+
handleNotifyRequests: mockMakeBatchNotifyRequest
3737
})
3838
)
3939

@@ -205,7 +205,7 @@ describe("Unit test for NHS Notify lambda handler", () => {
205205

206206
// returns true if the request ID is "fresh"
207207
mockCheckCooldownForUpdate.mockImplementation((logger, update) => {
208-
const u = update as { RequestID: string }
208+
const u = update as {RequestID: string}
209209
return Promise.resolve(u.RequestID === "fresh")
210210
})
211211

@@ -261,7 +261,7 @@ describe("Unit test for NHS Notify lambda handler", () => {
261261

262262
// returns true if the request ID is "fresh"
263263
mockCheckCooldownForUpdate.mockImplementation((logger, update) => {
264-
const u = update as { RequestID: string }
264+
const u = update as {RequestID: string}
265265
return Promise.resolve(u.RequestID === "fresh")
266266
})
267267

0 commit comments

Comments
 (0)