Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
01c3bfe
CCM-14974: Added 4 new events to the failure report
ScottFullerton-NHSE Mar 17, 2026
eaf48ac
CCM-14974: Trivy fix
ScottFullerton-NHSE Mar 17, 2026
52f3773
CCM-14974: Trivy fix
ScottFullerton-NHSE Mar 17, 2026
27f1f1c
CCM-14974: Fix non-null status
ScottFullerton-NHSE Mar 17, 2026
6b6e4a7
CCM-14974: Update sql
ScottFullerton-NHSE Mar 18, 2026
bf50c4d
CCM-14974: Trivy ignore
ScottFullerton-NHSE Mar 18, 2026
d27e0b9
CCM-14974: Update test to remove pdm check
ScottFullerton-NHSE Mar 18, 2026
7b0d390
Bump h3 from 1.15.5 to 1.15.8 in /src/eventcatalog
dependabot[bot] Mar 18, 2026
1936436
CCM-14974: Update package lock
ScottFullerton-NHSE Mar 19, 2026
6c8d3cc
Merge remote-tracking branch 'origin/main' into feature/CCM-14974-csv…
ScottFullerton-NHSE Mar 19, 2026
53cb1b9
CCM-14961: Fix trivy vulnerabilities
simonlabarere Mar 18, 2026
7bac1fb
Merge remote-tracking branch 'origin/dependabot/npm_and_yarn/src/even…
ScottFullerton-NHSE Mar 19, 2026
ffbc2ee
CCM-14974: Readd test and change it to message skipped
ScottFullerton-NHSE Mar 20, 2026
b7ee129
CCM-14974: Added failure reasons to events
ScottFullerton-NHSE Mar 25, 2026
d99ddc6
CCM-14974: Fix linting + typecheck
ScottFullerton-NHSE Mar 25, 2026
e59b8a6
CCM-14974: Fix linting
ScottFullerton-NHSE Mar 25, 2026
099e80e
CCM-14974: Fix unit tests
ScottFullerton-NHSE Mar 25, 2026
00037da
CCM-14974: Fix sonarcloud
ScottFullerton-NHSE Mar 25, 2026
a7102ed
CCM-14974: Add failure reason and code to component test
ScottFullerton-NHSE Mar 25, 2026
bae078d
Merge branch 'main' into feature/CCM-14974-csv-reports-failure-events
gareth-allan Mar 26, 2026
e2a818d
CCM-14974: Dependency updates
gareth-allan Mar 26, 2026
ee5d708
CCM-14974: Remove generate-csv util
gareth-allan Mar 26, 2026
5435af2
CCM-14974: Update package-lock
gareth-allan Mar 26, 2026
8859031
CCM-14974: Add failure codes to PrintLetterTransitioned events in com…
gareth-allan Mar 27, 2026
87f9d29
CCM-14974: Added failure codes to other failing component test scenarios
gareth-allan Mar 27, 2026
e6bbefc
CCM-14974: Expand notify-api-client test
gareth-allan Mar 27, 2026
69f07b7
Merge branch 'main' into feature/CCM-14974-csv-reports-failure-events
gareth-allan Mar 27, 2026
09d3943
Merge branch 'main' into feature/CCM-14974-csv-reports-failure-events
gareth-allan Mar 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
code,description
DL_PDMV_001,Letter rejected by PDM
DL_PDMV_002,Timeout waiting for letter storage
DL_CLIV_003,Attachment contains a virus
DL_INTE_001,Request rejected by Core API
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
resource "aws_glue_catalog_table" "failure_code_lookup" {
name = "failure_code_lookup"
description = "Lookup table for failure code descriptions"
database_name = aws_glue_catalog_database.reporting.name

table_type = "EXTERNAL_TABLE"

storage_descriptor {
location = "s3://${module.s3bucket_reporting.bucket}/reference-data/failure_codes/"

input_format = "org.apache.hadoop.mapred.TextInputFormat"
output_format = "org.apache.hadoop.hive.ql.io.HiveIgnoreKeyTextOutputFormat"

ser_de_info {
name = "csv"
serialization_library = "org.apache.hadoop.hive.serde2.OpenCSVSerde"

parameters = {
"separatorChar" = ","
"skip.header.line.count" = "1"
}
}

columns {
name = "code"
type = "string"
}

columns {
name = "description"
type = "string"
}
}

parameters = {
EXTERNAL = "TRUE"
classification = "csv"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Auto-generated CSV containing failure code definitions
# Source: src/digital-letters-events/failure-codes.ts
# Build: make build / make generate (runs generate-dependencies)
resource "aws_s3_object" "failure_codes" {
bucket = module.s3bucket_reporting.bucket
key = "reference-data/failure_codes/failure_codes.csv"
source = "${path.module}/data/failure_codes.csv"
content_type = "text/csv"
etag = filemd5("${path.module}/data/failure_codes.csv")

tags = merge(
local.default_tags,
{
Name = "${local.csi}-failure-codes-csv"
}
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,35 @@ WITH vars AS (
e.time,
CASE
WHEN e.type LIKE '%.item.dequeued.%'
OR e.type LIKE '%.queue.digital.letter.read.%' THEN 'Digital'
WHEN e.type LIKE '%.print.letter.transitioned.%' THEN 'Print' ELSE NULL
OR e.type LIKE '%.queue.digital.letter.read.%'
OR e.type LIKE '%.pdm.resource.submission.rejected.%'
OR e.type LIKE '%.pdm.resource.retries.exceeded.%'
OR e.type LIKE '%.messages.request.rejected.%' THEN 'Digital'
WHEN e.type LIKE '%.print.letter.transitioned.%'
OR e.type LIKE '%.print.file.quarantined.%' THEN 'Print' ELSE NULL
END as communicationtype,
CASE
WHEN e.type LIKE '%.item.dequeued.%' THEN 'Unread'
WHEN e.type LIKE '%.queue.digital.letter.read.%' THEN 'Read'
WHEN e.type LIKE '%.pdm.resource.submission.rejected.%' THEN 'Failed'
WHEN e.type LIKE '%.pdm.resource.retries.exceeded.%' THEN 'Failed'
WHEN e.type LIKE '%.messages.request.rejected.%' THEN 'Failed'
WHEN e.type LIKE '%.print.file.quarantined.%' THEN 'Failed'
WHEN e.letterstatus = 'RETURNED' THEN 'Returned'
WHEN e.letterstatus = 'FAILED' THEN 'Failed'
WHEN e.letterstatus = 'DISPATCHED' THEN 'Dispatched'
WHEN e.letterstatus = 'REJECTED' THEN 'Rejected' ELSE NULL
END as status
END as status,
e.reasoncode,
COALESCE(
CASE WHEN e.type LIKE '%.messages.request.rejected.%' THEN e.reasontext END,
fcl.description,
e.reasontext,
e.reasoncode
) as reasontext
FROM event_record e
CROSS JOIN vars v
LEFT JOIN failure_code_lookup fcl ON e.reasoncode = fcl.code
WHERE e.senderid = v.senderid
AND e.__year = year(v.dt)
AND e.__month = month(v.dt)
Expand All @@ -31,25 +47,31 @@ WITH vars AS (
ORDER BY te.time DESC,
CASE
-- Digital Priority Order
WHEN te.communicationtype = 'Digital' AND te.status = 'Failed' THEN 3
WHEN te.status = 'Read' THEN 2
WHEN te.status = 'Unread' THEN 1
-- Print Priority Order
WHEN te.status = 'Returned' THEN 4
WHEN te.status = 'Failed' THEN 3
WHEN te.communicationtype = 'Print' AND te.status = 'Failed' THEN 3
WHEN te.status = 'Dispatched' THEN 2
WHEN te.status = 'Rejected' THEN 1 ELSE 0
END DESC
) AS "row_number",
te.messagereference,
te.time,
te.communicationtype,
te.status
te.status,
te.reasoncode,
te.reasontext
FROM "translated_events" AS te
where te.status IS NOT NULL
WHERE te.status IS NOT NULL
AND te.communicationtype IS NOT NULL
)
SELECT oe.messagereference as "Message Reference",
oe.time as "Time",
oe.communicationtype as "Communication Type",
oe.status as "Status"
oe.status as "Status",
oe.reasoncode as "Reason Code",
oe.reasontext as "Reason"
FROM "ordered_events" AS oe
WHERE oe.row_number = 1
Original file line number Diff line number Diff line change
Expand Up @@ -256,11 +256,13 @@ describe('createHandler', () => {
const handler = createHandler(dependencies);
const { messageId } = sqsEvent.Records[0];
const errorCode = 'VALIDATION_ERROR';
const failureReason = 'Request validation failed';
const correlationId = 'corr-123';
const error = new RequestNotifyError(
new Error('Validation failed'),
correlationId,
errorCode,
failureReason,
);
// Add messageReference property dynamically to trigger the terminal error path
(error as any).messageReference = messageReference;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,8 +211,11 @@ describe('sendRequest', () => {
mockRequest1.data.attributes.messageReference,
),
).rejects.toMatchObject({
errorCode: 'CM_MISSING_ROUTING_PLAN_TEMPLATE',
cause: error,
correlationId: 'request-item-id_request-item-plan-id',
errorCode: 'CM_MISSING_ROUTING_PLAN_TEMPLATE',
failureReason:
'The templates required to use the routing plan were not found.',
});
},
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,15 @@ describe('mapper', () => {
describe('mapPdmEventToMessageRequestRejected', () => {
it('correctly maps PDM event to MessageRequestRejected', () => {
const failureCode = 'INVALID_NHS_NUMBER';
const failureReason = 'NHS number is not valid';
const mockDate = new Date('2024-01-15T12:00:00Z');
jest.spyOn(globalThis, 'Date').mockImplementation(() => mockDate as any);

const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result).toEqual({
Expand All @@ -232,40 +234,64 @@ describe('mapper', () => {
failureCode: 'INVALID_NHS_NUMBER',
messageUri:
'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=resource-789',
reasonCode: 'DL_INTE_001',
reasonText: 'NHS number is not valid',
},
});

expect(mockRandomUUID).toHaveBeenCalled();
});

it('includes reasonCode and reasonText for reporting', () => {
const failureCode = 'CM_DUPLICATE_REQUEST';
const failureReason = 'This request has already been received';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.data.reasonCode).toBe('DL_INTE_001');
expect(result.data.reasonText).toBe(
'This request has already been received',
);
});

it('generates new UUID for event', () => {
const failureCode = 'VALIDATION_ERROR';
const failureReason = 'Request validation failed';
mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(mockRandomUUID).toHaveBeenCalledTimes(1);
});

it('includes failureCode in data', () => {
const failureCode = 'ROUTING_FAILED';
const failureReason = 'Unable to route message';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.data.failureCode).toBe('ROUTING_FAILED');
});

it('includes messageUri with resource ID', () => {
const failureCode = 'TIMEOUT';
const failureReason = 'Request timed out';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.data.messageUri).toBe(
Expand All @@ -275,32 +301,38 @@ describe('mapper', () => {

it('uses sender senderId in data', () => {
const failureCode = 'UNKNOWN_ERROR';
const failureReason = 'An unknown error occurred';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.data.senderId).toBe('test-sender-id');
});

it('uses messageReference from PDM event', () => {
const failureCode = 'DUPLICATE_REQUEST';
const failureReason = 'Duplicate request detected';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.data.messageReference).toBe('msg-ref-123');
});

it('sets correct event type', () => {
const failureCode = 'SYSTEM_ERROR';
const failureReason = 'System error occurred';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.type).toBe(
Expand All @@ -310,10 +342,12 @@ describe('mapper', () => {

it('sets correct dataschema', () => {
const failureCode = 'CONFIG_ERROR';
const failureReason = 'Configuration error';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.dataschema).toBe(
Expand All @@ -323,10 +357,12 @@ describe('mapper', () => {

it('preserves CloudEvents properties from PDM event', () => {
const failureCode = 'NETWORK_ERROR';
const failureReason = 'Network connection failed';
const result = mapPdmEventToMessageRequestRejected(
mockPdmEvent,
mockSender,
failureCode,
failureReason,
);

expect(result.specversion).toBe('1.0');
Expand Down
1 change: 1 addition & 0 deletions lambdas/core-notifier-lambda/src/apis/sqs-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ async function processSqsRecord(
incoming,
sender,
error.errorCode,
error.failureReason,
);
} else {
// this might be a transient error so we notify the queue to retry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export class NotifyClient implements INotifyClient {
error,
correlationId,
errorBody?.errors[0].code,
errorBody?.errors[0].detail,
);
}
}
Expand Down
5 changes: 5 additions & 0 deletions lambdas/core-notifier-lambda/src/domain/mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
} from 'digital-letters-events';
import type { SingleMessageRequest } from 'domain/request';

const CORE_API_FAILURE_CODE = 'DL_INTE_001';

const DIGITAL_LETTER_URL =
'https://www.nhsapp.service.nhs.uk/digital-letters?letterid=';

Expand Down Expand Up @@ -99,6 +101,7 @@ export function mapPdmEventToMessageRequestRejected(
pdmResourceAvailable: PDMResourceAvailable,
sender: Sender,
notifyFailureCode: string,
failureReason: string,
): MessageRequestRejected {
const { data } = pdmResourceAvailable;
const { messageReference } = data;
Expand All @@ -117,6 +120,8 @@ export function mapPdmEventToMessageRequestRejected(
senderId: sender.senderId,
failureCode: notifyFailureCode,
messageUri: `${DIGITAL_LETTER_URL}${data.resourceId}`,
reasonCode: CORE_API_FAILURE_CODE,
reasonText: failureReason,
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,19 @@ export class RequestNotifyError extends Error {

readonly errorCode: string;

constructor(cause: Error, correlationId: string, errorCode: string) {
readonly failureReason: string;

constructor(
cause: Error,
correlationId: string,
errorCode: string,
failureReason: string,
) {
super('Error received from Core Notify API');

this.cause = cause;
this.correlationId = correlationId;
this.errorCode = errorCode;
this.failureReason = failureReason;
}
}
1 change: 1 addition & 0 deletions lambdas/core-notifier-lambda/src/domain/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export type SingleMessageErrorResponse = {
{
id: string;
code: string;
detail: string;
},
];
};
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ describe('sqs-handler', () => {
senderId: 'sender-id-2',
letterUri: 'https://bucket/key2',
createdAt: '2024-01-01T00:00:00Z',
reasonCode: 'DL_CLIV_003',
},
subject: 'test-subject',
traceparent: 'test-traceparent',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ describe('mapper', () => {
senderId,
letterUri,
createdAt,
reasonCode: 'DL_CLIV_003',
},
recordedtime: '2024-01-15T10:30:00.000Z',
severitynumber: 2,
Expand Down
Loading
Loading