Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,9 @@
resource "aws_lambda_event_source_mapping" "letter_status_update" {
event_source_arn = module.letter_status_updates_queue.sqs_queue_arn
function_name = module.letter_status_update.function_name
batch_size = 10
maximum_batching_window_in_seconds = 5
function_response_types = [
"ReportBatchItemFailures"
]
}
2 changes: 2 additions & 0 deletions infrastructure/terraform/components/api/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ locals {
SUPPLIER_ID_HEADER = "nhsd-supplier-id",
APIM_CORRELATION_HEADER = "nhsd-correlation-id",
DOWNLOAD_URL_TTL_SECONDS = 60
SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
}

core_pdf_bucket_arn = "arn:aws:s3:::comms-${var.core_account_id}-eu-west-2-${var.core_environment}-api-stg-pdf-pipeline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ data "aws_iam_policy_document" "letter_status_update" {
actions = [
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:UpdateItem",
]

resources = [
Expand All @@ -79,7 +78,20 @@ data "aws_iam_policy_document" "letter_status_update" {
]

resources = [
module.letter_status_updates_queue.sqs_queue_arn
module.letter_status_updates_queue.sqs_queue_arn
]
}

statement {
sid = "AllowSNSPublish"
effect = "Allow"

actions = [
"sns:Publish"
]

resources = [
module.eventsub.sns_topic.arn
]
}
}
54 changes: 54 additions & 0 deletions internal/datastore/src/__test__/letter-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ function createLetter(
supplierId: string,
letterId: string,
status: Letter["status"] = "PENDING",
eventId?: string,
): InsertLetter {
const now = new Date().toISOString();
return {
id: letterId,
eventId,
supplierId,
specificationId: "specification1",
groupId: "group1",
Expand Down Expand Up @@ -168,6 +170,7 @@ describe("LetterRepository", () => {

const updateLetter: UpdateLetter = {
id: "letter1",
eventId: "event1",
supplierId: "supplier1",
status: "REJECTED",
reasonCode: "R01",
Expand All @@ -180,6 +183,7 @@ describe("LetterRepository", () => {
"letter1",
);
expect(updatedLetter.status).toBe("REJECTED");
expect(updatedLetter.previousStatus).toBe("PENDING");
expect(updatedLetter.reasonCode).toBe("R01");
expect(updatedLetter.reasonText).toBe("Reason text");
});
Expand All @@ -199,6 +203,7 @@ describe("LetterRepository", () => {
jest.setSystemTime(new Date(2020, 1, 2));
const letterDto: UpdateLetter = {
id: "letter1",
eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
Expand All @@ -215,6 +220,7 @@ describe("LetterRepository", () => {
test("can't update a letter that does not exist", async () => {
const updateLetter: UpdateLetter = {
id: "letter1",
eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
Expand All @@ -233,6 +239,7 @@ describe("LetterRepository", () => {

const updateLetter: UpdateLetter = {
id: "letter1",
eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
Expand All @@ -241,6 +248,52 @@ describe("LetterRepository", () => {
).rejects.toThrow("Cannot do operations on a non-existent table");
});

test("does not update a letter if the same eventId is used", async () => {
const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
await letterRepository.putLetter(letter);

const duplicateUpdate: UpdateLetter = {
id: "letter1",
eventId: "event1",
supplierId: "supplier1",
status: "REJECTED",
reasonCode: "R01",
};
const result = await letterRepository.updateLetterStatus(duplicateUpdate);

expect(result).toBeUndefined();
const unchangedLetter = await letterRepository.getLetterById(
"supplier1",
"letter1",
);
expect(unchangedLetter.status).toBe("DELIVERED");
expect(unchangedLetter.eventId).toBe("event1");
expect(unchangedLetter.reasonCode).toBeUndefined();
});

test("updates a letter if a different eventId is used", async () => {
const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
await letterRepository.putLetter(letter);

const duplicateUpdate: UpdateLetter = {
id: "letter1",
eventId: "event2",
supplierId: "supplier1",
status: "REJECTED",
reasonCode: "R01",
};
const result = await letterRepository.updateLetterStatus(duplicateUpdate);

expect(result).toBeDefined();
const changedLetter = await letterRepository.getLetterById(
"supplier1",
"letter1",
);
expect(changedLetter.status).toBe("REJECTED");
expect(changedLetter.eventId).toBe("event2");
expect(changedLetter.reasonCode).toBe("R01");
});

test("should return a list of letters matching status", async () => {
await letterRepository.putLetter(createLetter("supplier1", "letter1"));
await letterRepository.putLetter(createLetter("supplier1", "letter2"));
Expand Down Expand Up @@ -278,6 +331,7 @@ describe("LetterRepository", () => {

const updateLetter: UpdateLetter = {
id: "letter1",
eventId: "event1",
supplierId: "supplier1",
status: "DELIVERED",
};
Expand Down
75 changes: 45 additions & 30 deletions internal/datastore/src/letter-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
UpdateCommand,
UpdateCommandOutput,
} from "@aws-sdk/lib-dynamodb";
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import { Logger } from "pino";
import { z } from "zod";
import {
Expand Down Expand Up @@ -73,7 +74,7 @@
async unsafePutLetterBatch(letters: InsertLetter[]): Promise<void> {
let lettersDb: Letter[] = [];
for (let i = 0; i < letters.length; i++) {
const letter = letters[i];

Check warning on line 77 in internal/datastore/src/letter-repository.ts

View workflow job for this annotation

GitHub Actions / Test stage / Linting

Variable Assigned to Object Injection Sink

if (letter) {
lettersDb.push({
Expand Down Expand Up @@ -163,32 +164,16 @@
};
}

async updateLetterStatus(letterToUpdate: UpdateLetter): Promise<Letter> {
async updateLetterStatus(
letterToUpdate: UpdateLetter,
): Promise<Letter | undefined> {
this.log.debug(
`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
);
let result: UpdateCommandOutput;
try {
let updateExpression =
"set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl";
const expressionAttributeValues: Record<string, any> = {
":status": letterToUpdate.status,
":updatedAt": new Date().toISOString(),
":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
":ttl": Math.floor(
Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
),
};

if (letterToUpdate.reasonCode) {
updateExpression += ", reasonCode = :reasonCode";
expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
}

if (letterToUpdate.reasonText) {
updateExpression += ", reasonText = :reasonText";
expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
}
const { expressionAttributeValues, updateExpression } =
this.buildUpdateExpression(letterToUpdate);

result = await this.ddbClient.send(
new UpdateCommand({
Expand All @@ -198,31 +183,61 @@
supplierId: letterToUpdate.supplierId,
},
UpdateExpression: updateExpression,
ConditionExpression: "attribute_exists(id)", // Ensure letter exists
ConditionExpression:
"attribute_exists(id) AND (attribute_not_exists(eventId) OR eventId <> :eventId)",
ExpressionAttributeNames: {
"#status": "status",
"#ttl": "ttl",
},
ExpressionAttributeValues: expressionAttributeValues,
ReturnValues: "ALL_NEW",
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
}),
);

this.log.debug(
`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
);
return LetterSchema.parse(result.Attributes);
} catch (error) {
if (
error instanceof Error &&
error.name === "ConditionalCheckFailedException"
) {
if (error instanceof ConditionalCheckFailedException) {
if (error.Item?.eventId.S === letterToUpdate.eventId) {
this.log.warn(
`Skipping update for letter ${letterToUpdate.id}: eventId ${letterToUpdate.eventId} already processed`,
);
return undefined;
}
throw new Error(
`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`,
);
}
throw error;
}
}

this.log.debug(
`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
);
return LetterSchema.parse(result.Attributes);
private buildUpdateExpression(letterToUpdate: UpdateLetter) {
let updateExpression = `set #status = :status, previousStatus = #status, updatedAt = :updatedAt, supplierStatus = :supplierStatus,
#ttl = :ttl, eventId = :eventId`;
const expressionAttributeValues: Record<string, any> = {
":status": letterToUpdate.status,
":updatedAt": new Date().toISOString(),
":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
":ttl": Math.floor(
Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
),
":eventId": letterToUpdate.eventId,
};

if (letterToUpdate.reasonCode) {
updateExpression += ", reasonCode = :reasonCode";
expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
}

if (letterToUpdate.reasonText) {
updateExpression += ", reasonText = :reasonText";
expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
}
return { updateExpression, expressionAttributeValues };
}

async getLettersBySupplier(
Expand Down
3 changes: 3 additions & 0 deletions internal/datastore/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,11 @@ export const LetterSchemaBase = z.object({

export const LetterSchema = LetterSchemaBase.extend({
supplierId: idRef(SupplierSchema, "id"),
eventId: z.string().optional(),
url: z.url(),
createdAt: z.string(),
updatedAt: z.string(),
previousStatus: LetterStatus.optional(),
supplierStatus: z.string().describe("Secondary index PK"),
supplierStatusSk: z.string().describe("Secondary index SK"),
ttl: z.int(),
Expand All @@ -67,6 +69,7 @@ export type InsertLetter = Omit<
>;
export type UpdateLetter = {
id: string;
eventId: string;
supplierId: string;
status: Letter["status"];
reasonCode?: string;
Expand Down
2 changes: 1 addition & 1 deletion internal/events/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const baseJestConfig: Config = {
},
},

coveragePathIgnorePatterns: ["/__tests__/"],
coveragePathIgnorePatterns: ["/src/index.ts$", "/__tests__/"],
transform: { "^.+\\.ts$": "ts-jest" },
testPathIgnorePatterns: [".build"],
testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"],
Expand Down
3 changes: 2 additions & 1 deletion internal/events/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"dependencies": {
"@asyncapi/bundler": "^0.6.4",
"@internal/datastore": "*",
"zod": "^4.1.11"
},
"description": "Schemas for NHS Notify Supplier API events",
Expand Down Expand Up @@ -50,5 +51,5 @@
"typecheck": "tsc --noEmit"
},
"types": "dist/index.d.ts",
"version": "1.0.9"
"version": "1.0.10"
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { $LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { Letter } from "@internal/datastore";
import mapLetterToCloudEvent from "../letter-mapper";
import { mapLetterToCloudEvent } from "../letter-mapper";

describe("letter-mapper", () => {
it("maps a letter to a letter event", async () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { LetterEvent } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src";
import { randomBytes, randomUUID } from "node:crypto";
import eventSchemaPackage from "@nhsdigital/nhs-notify-event-schemas-supplier-api/package.json";
import { LetterForEventPub } from "../types";
import { Letter } from "@internal/datastore";
import { LetterEvent } from "./letter-events";

export default function mapLetterToCloudEvent(
letter: LetterForEventPub,
// eslint-disable-next-line import-x/prefer-default-export
export function mapLetterToCloudEvent(
letter: Letter,
source: string,
): LetterEvent {
const eventId = randomUUID();
Expand Down
1 change: 1 addition & 0 deletions internal/events/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as DomainBase } from "./domain/domain-base";
export * from "./events/event-envelope";
export * from "./events/letter-events";
export * from "./events/mi-events";
export * from "./events/letter-mapper";
3 changes: 1 addition & 2 deletions internal/events/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
"declaration": true,
"isolatedModules": true,
"module": "commonjs",
"outDir": "dist",
"resolveJsonModule": true
"outDir": "dist"
},
"exclude": [
"node_modules",
Expand Down
2 changes: 2 additions & 0 deletions lambdas/api-handler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.925.0",
"@aws-sdk/client-s3": "^3.925.0",
"@aws-sdk/client-sns": "^3.925.0",
"@aws-sdk/client-sqs": "^3.925.0",
"@aws-sdk/lib-dynamodb": "^3.925.0",
"@aws-sdk/s3-request-presigner": "^3.925.0",
"@internal/datastore": "*",
"@internal/helpers": "*",
"@nhsdigital/nhs-notify-event-schemas-supplier-api": "*",
"aws-lambda": "^1.0.7",
"esbuild": "^0.25.11",
"pino": "^9.7.0",
Expand Down
Loading
Loading