Skip to content

Commit 95e7cf2

Browse files
authored
New: [AEA-0000] - Add logic to handle the channel status callback updates (#2074)
## Summary - ✨ New Feature ### Details The Notifications callback lambda needs to be able to handle data from both of these schemas: https://digital.nhs.uk/developer/api-catalogue/nhs-notify#post-/%3Cclient-provided-message-status-URI%3E https://digital.nhs.uk/developer/api-catalogue/nhs-notify#post-/%3Cclient-provided-channel-status-URI%3E The message status is already implemented - this PR adds the channel status update logic
1 parent 03a7083 commit 95e7cf2

5 files changed

Lines changed: 280 additions & 88 deletions

File tree

packages/nhsNotifyUpdateCallback/src/helpers.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {createHmac, timingSafeEqual} from "crypto"
99

1010
import {LastNotificationStateType} from "@PrescriptionStatusUpdate_common/commonTypes"
1111

12-
import {MessageStatusResponse} from "./types"
12+
import {CallbackResponse, CallbackType} from "./types"
1313

1414
const APP_NAME_SECRET = process.env.APP_NAME_SECRET
1515
const API_KEY_SECRET = process.env.API_KEY_SECRET
@@ -105,14 +105,15 @@ export async function checkSignature(logger: Logger, event: APIGatewayProxyEvent
105105

106106
// Must be same length for timingSafeEqual
107107
if (givenSigBuf.length !== expectedSigBuf.length ||
108-
!timingSafeEqual(expectedSigBuf, givenSigBuf)) {
108+
!timingSafeEqual(expectedSigBuf, givenSigBuf)) {
109109
logger.error("Incorrect signature given", {
110110
expectedSignature: expectedSigBuf.toString("hex"),
111111
givenSignature: signature
112112
})
113113
return response(403, {message: "Incorrect signature"})
114114
}
115115

116+
logger.info("Signature OK!")
116117
return undefined
117118
}
118119

@@ -124,11 +125,29 @@ export async function checkSignature(logger: Logger, event: APIGatewayProxyEvent
124125
*/
125126
export async function updateNotificationsTable(
126127
logger: Logger,
127-
bodyData: MessageStatusResponse
128+
bodyData: CallbackResponse
128129
): Promise<void> {
129130
// For each callback resource, return a promise
130131
const callbackPromises = bodyData.data.map(async (resource) => {
131-
const {messageId, messageStatus, timestamp} = resource.attributes
132+
let messageId: string
133+
let messageStatus: string
134+
let timestamp: string
135+
136+
if (resource.type === CallbackType.message) {
137+
messageId = resource.attributes.messageId
138+
messageStatus = resource.attributes.messageStatus
139+
timestamp = resource.attributes.timestamp
140+
} else if (resource.type === CallbackType.channel) {
141+
messageId = resource.attributes.messageId
142+
messageStatus = resource.attributes.channelStatus
143+
timestamp = resource.attributes.timestamp
144+
} else {
145+
logger.error("Unknown data structure - cannot store to notifications table.", {resource})
146+
// Set to junk data, so that when we try and update the table we will fail. This is fine, and handled later.
147+
messageId = "unknown"
148+
messageStatus = "unknown"
149+
timestamp = "unknown"
150+
}
132151

133152
// Query matching records
134153
let queryResult

packages/nhsNotifyUpdateCallback/src/lambdaHandler.ts

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import httpHeaderNormalizer from "@middy/http-header-normalizer"
99

1010
import errorHandler from "@nhs/fhir-middy-error-handler"
1111

12-
import {MessageStatusResponse} from "./types"
12+
import {CallbackType, CallbackResponse} from "./types"
1313
import {checkSignature, response, updateNotificationsTable} from "./helpers"
1414

1515
export const logger = new Logger({serviceName: "nhsNotifyUpdateCallback"})
@@ -20,36 +20,53 @@ const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPro
2020
"apigw-request-id": event.headers["apigw-request-id"],
2121
"x-request-id": event.headers["x-request-id"]
2222
})
23-
logger.info("Lambda called with this event", {event})
2423

2524
// Require a request ID
2625
if (!event.headers["x-request-id"]) return response(400, {message: "No x-request-id given"})
2726

2827
// Check the request signature
2928
const isErr = await checkSignature(logger, event)
3029
if (isErr) return isErr
31-
logger.info("Signature OK!")
3230

3331
// Parse out the request body
3432
if (!event.body) return response(400, {message: "No request body given"})
35-
let payload: MessageStatusResponse
33+
let payload: CallbackResponse
3634
try {
3735
payload = JSON.parse(event.body)
3836
} catch (error) {
3937
logger.error("Failed to parse payload", {error, payload: event.body})
4038
return response(400, {message: "Request body failed to parse"})
4139
}
4240

41+
let receivedUnknownCallbackType = false
4342
payload.data.forEach(m => {
44-
logger.info(
45-
"Message state updated",
46-
{
43+
let logPayload = {}
44+
if (m.type === CallbackType.message) {
45+
logPayload = {
46+
callbackType: m.type,
4747
messageStatus: m.attributes.messageStatus,
4848
messageReference: m.attributes.messageReference,
4949
messageId: m.attributes.messageId,
5050
receivedTimestamp: m.attributes.timestamp
5151
}
52-
)
52+
53+
} else if (m.type === CallbackType.channel) {
54+
logPayload = {
55+
callbackType: m.type,
56+
messageStatus: m.attributes.channelStatus,
57+
supplierStatus: m.attributes.supplierStatus ?? "not given",
58+
retryCount: m.attributes.retryCount,
59+
messageReference: m.attributes.messageReference,
60+
messageId: m.attributes.messageId,
61+
receivedTimestamp: m.attributes.timestamp
62+
}
63+
} else {
64+
logger.warn("Unknown callback data structure.", {data: m})
65+
receivedUnknownCallbackType = true
66+
}
67+
68+
// If we have populated the logPayload object, then log it.
69+
if (Object.keys(logPayload).length > 0) logger.info("Message state updated", logPayload)
5370
})
5471

5572
try {
@@ -59,11 +76,23 @@ const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPro
5976
return response(500, {message: "Failed to update the notification state table"})
6077
}
6178

62-
// All's well that ends well
63-
return {
64-
statusCode: 202,
65-
body: "OK"
79+
if (receivedUnknownCallbackType) {
80+
logger.info(
81+
"Detected some unknown callback types. Returning 400 despite possible partial success."
82+
)
83+
return response(
84+
400,
85+
{
86+
message: (
87+
"Received an unknown callback data type. expected data[].type"
88+
+ " to always be either ChannelStatus or MessageStatus"
89+
)
90+
}
91+
)
6692
}
93+
94+
// All's well that ends well
95+
return response(202)
6796
}
6897

6998
export const handler = middy(lambdaHandler)
Lines changed: 119 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,79 +1,133 @@
1-
// Enums
2-
export type MessageStatus =
3-
| "created"
4-
| "pending_enrichment"
5-
| "enriched"
6-
| "sending"
7-
| "delivered"
8-
| "failed";
9-
10-
export type ChannelType =
11-
| "nhsapp"
12-
| "email"
13-
| "sms"
14-
| "letter";
15-
16-
export type ChannelStatus =
17-
| "created"
18-
| "sending"
19-
| "delivered"
20-
| "failed"
21-
| "skipped";
22-
23-
// Callback return schema
1+
// --- Literal sets ------------------------------------------------------------
2+
3+
export enum CallbackType {
4+
message = "MessageStatus",
5+
channel = "ChannelStatus"
6+
}
7+
8+
export const MessageStatuses = [
9+
"created",
10+
"pending_enrichment",
11+
"enriched",
12+
"sending",
13+
"delivered",
14+
"failed"
15+
] as const
16+
export type MessageStatus = typeof MessageStatuses[number];
17+
18+
export const ChannelTypes = ["nhsapp", "email", "sms", "letter"] as const
19+
export type ChannelType = typeof ChannelTypes[number];
20+
21+
export const ChannelStatuses = [
22+
"created",
23+
"sending",
24+
"delivered",
25+
"failed",
26+
"skipped"
27+
] as const
28+
export type ChannelStatus = typeof ChannelStatuses[number];
29+
30+
export const CascadeTypes = ["primary", "secondary"] as const
31+
export type CascadeType = typeof CascadeTypes[number];
32+
33+
export const SupplierStatuses = [
34+
"accepted",
35+
"cancelled",
36+
"delivered",
37+
"notification_attempted",
38+
"notified",
39+
"pending_virus_check",
40+
"permanent_failure",
41+
"read",
42+
"received",
43+
"rejected",
44+
"technical_failure",
45+
"temporary_failure",
46+
"unnotified",
47+
"unknown",
48+
"validation_failed"
49+
] as const
50+
export type SupplierStatus = typeof SupplierStatuses[number];
51+
52+
// --- Core models -------------------------------------------------------------
53+
2454
export interface Channel {
25-
/** The communication type of this channel */
26-
type: ChannelType;
27-
/** Current status of this channel */
28-
channelStatus: ChannelStatus;
55+
readonly type: ChannelType;
56+
readonly channelStatus: ChannelStatus;
2957
}
3058

3159
export interface RoutingPlan {
32-
/** Identifier for the routing plan */
33-
id: string;
34-
/** Name of the routing plan */
35-
name: string;
36-
/** Specific version of the routing plan */
37-
version: string;
38-
/** Creation date of the routing plan */
39-
createdDate: string;
60+
readonly id: string;
61+
readonly name: string;
62+
readonly version: string;
63+
readonly createdDate: string;
4064
}
4165

42-
export interface MessageStatusAttributes {
43-
/** Unique identifier for the message */
44-
messageId: string;
45-
/** Original reference supplied for the message */
46-
messageReference: string;
66+
// Shared attributes across callbacks
67+
interface BaseAttributes {
68+
/** Notify ID for the message (KSUID; 27-char alphanumeric) */
69+
readonly messageId: string;
70+
/** Original reference supplied by us for the message */
71+
readonly messageReference: string;
72+
/** Timestamp of the callback event */
73+
readonly timestamp: string;
74+
}
75+
76+
export interface MessageStatusAttributes extends BaseAttributes {
4777
/** Aggregate status across all channels */
48-
messageStatus: MessageStatus;
49-
/** Extra information about the message status, if any */
50-
messageStatusDescription?: string;
78+
readonly messageStatus: MessageStatus;
79+
readonly messageStatusDescription?: string;
5180
/** List of channels attempted for delivery */
52-
channels: Array<Channel>;
53-
/** Timestamp of the callback event */
54-
timestamp: string;
55-
/** Routing plan details */
56-
routingPlan: RoutingPlan;
81+
readonly channels: ReadonlyArray<Channel>;
82+
readonly routingPlan: RoutingPlan;
5783
}
5884

59-
export interface MessageStatusResource {
60-
/** Always "MessageStatus" */
61-
type: "MessageStatus";
62-
attributes: MessageStatusAttributes;
63-
links: {
64-
/** URL to retrieve the overarching message status */
65-
message: string;
66-
};
67-
meta: {
68-
/** Key to deduplicate retried requests */
69-
idempotencyKey: string;
70-
};
85+
export interface ChannelStatusAttributes extends BaseAttributes {
86+
readonly cascadeType?: CascadeType;
87+
readonly cascadeOrder?: number;
88+
readonly channel: ChannelType;
89+
readonly channelStatus: ChannelStatus;
90+
/** Extra information associated with the status of this channel */
91+
readonly channelStatusDescription?: string;
92+
readonly supplierStatus?: SupplierStatus;
93+
readonly retryCount: number;
7194
}
7295

96+
// --- Generic JSON-ish resource -----------------------------------------------
97+
98+
interface Links {
99+
/** URL to retrieve the overarching message status */
100+
readonly message: string;
101+
}
102+
103+
interface Meta {
104+
/** Key to deduplicate retried requests */
105+
readonly idempotencyKey: string;
106+
}
107+
108+
interface Resource<CallbackType, TAttributes> {
109+
readonly type: CallbackType;
110+
readonly attributes: TAttributes;
111+
readonly links: Links;
112+
readonly meta: Meta;
113+
}
114+
115+
// Concrete resources
116+
export type MessageStatusResource = Resource<"MessageStatus", MessageStatusAttributes>;
117+
export type ChannelStatusResource = Resource<"ChannelStatus", ChannelStatusAttributes>;
118+
119+
// --- Responses ---------------------------------------------------------------
120+
73121
export interface MessageStatusResponse {
74-
/**
75-
* Array of MessageStatus resources.
76-
* Must contain at least one element.
77-
*/
78-
data: Array<MessageStatusResource>;
122+
readonly data: Array<MessageStatusResource>;
123+
}
124+
125+
export interface ChannelStatusResponse {
126+
readonly data: Array<ChannelStatusResource>;
127+
}
128+
129+
export type CallbackResource = MessageStatusResource | ChannelStatusResource;
130+
131+
export interface CallbackResponse {
132+
readonly data: Array<CallbackResource>;
79133
}

0 commit comments

Comments
 (0)