diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index 1b60ce0a..1392cc33 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -40,6 +40,7 @@ No requirements.
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a |
| [logging\_bucket](#module\_logging\_bucket) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a |
| [patch\_letter](#module\_patch\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
+| [post\_mi](#module\_post\_mi) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| [s3bucket\_test\_letters](#module\_s3bucket\_test\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a |
| [supplier\_ssl](#module\_supplier\_ssl) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-ssl.zip | n/a |
## Outputs
diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
index c4cd1b16..2e90b260 100644
--- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
+++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
@@ -50,9 +50,10 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {
resources = [
module.authorizer_lambda.function_arn,
module.get_letter.function_arn,
+ module.get_letter_data.function_arn,
module.get_letters.function_arn,
module.patch_letter.function_arn,
- module.get_letter_data.function_arn
+ module.post_mi.function_arn
]
}
}
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index 5a2b4aa5..513e8da1 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -5,22 +5,25 @@ locals {
root_domain_nameservers = local.acct.route53_zone_nameservers["supplier-api"]
openapi_spec = templatefile("${path.module}/resources/spec.tmpl.json", {
- APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn
- AWS_REGION = var.region
- AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn
- GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn
- GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn
+ APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn
+ AWS_REGION = var.region
+ AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn
+ GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn
+ GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn
GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn
- PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
+ PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
+ POST_MI_LAMBDA_ARN = module.post_mi.function_arn
})
destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs"
common_lambda_env_vars = {
- LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
- LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours
- SUPPLIER_ID_HEADER = "nhsd-supplier-id",
- APIM_CORRELATION_HEADER = "nhsd-correlation-id",
+ LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
+ MI_TABLE_NAME = aws_dynamodb_table.mi.name,
+ LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours
+ MI_TTL_HOURS = 2160 # 90 days * 24 hours
+ SUPPLIER_ID_HEADER = "nhsd-supplier-id",
+ APIM_CORRELATION_HEADER = "nhsd-correlation-id",
DOWNLOAD_URL_TTL_SECONDS = 60
}
}
diff --git a/infrastructure/terraform/components/api/module_lambda_post_mi.tf b/infrastructure/terraform/components/api/module_lambda_post_mi.tf
new file mode 100644
index 00000000..9398a3cf
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_post_mi.tf
@@ -0,0 +1,68 @@
+module "post_mi" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip"
+
+ function_name = "post_mi"
+ description = "Add management information"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ group = var.group
+
+ log_retention_in_days = var.log_retention_in_days
+ kms_key_arn = module.kms.key_arn
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.post_mi_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "api-handler/dist"
+ function_include_common = true
+ handler_function_name = "postMI"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 5
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ send_to_firehose = true
+ log_destination_arn = local.destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = merge(local.common_lambda_env_vars, {})
+}
+
+data "aws_iam_policy_document" "post_mi_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn, ## Requires shared kms module
+ ]
+ }
+
+ statement {
+ sid = "AllowDynamoDBAccess"
+ effect = "Allow"
+
+ actions = [
+ "dynamodb:PutItem",
+ ]
+
+ resources = [
+ aws_dynamodb_table.mi.arn,
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json
index 50896857..4514619b 100644
--- a/infrastructure/terraform/components/api/resources/spec.tmpl.json
+++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json
@@ -202,6 +202,45 @@
}
}
]
+ },
+ "/mi": {
+ "post": {
+ "description": "Provide management information.",
+ "operationId": "postMI",
+ "requestBody": {
+ "required": true
+ },
+ "responses": {
+ "201": {
+ "description": "Resource created"
+ },
+ "400": {
+ "description": "Bad request, invalid input data"
+ },
+ "500": {
+ "description": "Server error"
+ }
+ },
+ "security": [
+ {
+ "LambdaAuthorizer": []
+ }
+ ],
+ "x-amazon-apigateway-integration": {
+ "contentHandling": "CONVERT_TO_TEXT",
+ "credentials": "${APIG_EXECUTION_ROLE_ARN}",
+ "httpMethod": "POST",
+ "passthroughBehavior": "WHEN_NO_TEMPLATES",
+ "responses": {
+ ".*": {
+ "statusCode": "200"
+ }
+ },
+ "timeoutInMillis": 29000,
+ "type": "AWS_PROXY",
+ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${POST_MI_LAMBDA_ARN}/invocations"
+ }
+ }
}
}
}
diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts
index cd59b16f..85f89193 100644
--- a/internal/datastore/src/__test__/db.ts
+++ b/internal/datastore/src/__test__/db.ts
@@ -30,7 +30,9 @@ export async function setupDynamoDBContainer() {
region: 'us-west-2',
endpoint,
lettersTableName: 'letters',
- ttlHours: 1
+ miTableName: 'management-info',
+ lettersTtlHours: 1,
+ miTtlHours: 1
};
return {
@@ -44,10 +46,7 @@ export async function setupDynamoDBContainer() {
export type DBContext = Awaited>;
-export async function createTables(context: DBContext) {
- const { ddbClient } = context;
-
- await ddbClient.send(new CreateTableCommand({
+const createLetterTableCommand = new CreateTableCommand({
TableName: 'letters',
BillingMode: 'PAY_PER_REQUEST',
KeySchema: [
@@ -72,15 +71,37 @@ export async function createTables(context: DBContext) {
{ AttributeName: 'supplierStatus', AttributeType: 'S' },
{ AttributeName: 'supplierStatusSk', AttributeType: 'S' },
]
- }));
+ });
- await ddbClient.send(new UpdateTimeToLiveCommand({
+const updateTimeToLiveCommand = new UpdateTimeToLiveCommand({
TableName: 'letters',
TimeToLiveSpecification: {
AttributeName: 'ttl',
Enabled: true
}
- }));
+ });
+
+const createMITableCommand = new CreateTableCommand({
+ TableName: 'management-info',
+ BillingMode: 'PAY_PER_REQUEST',
+ KeySchema: [
+ { AttributeName: 'supplierId', KeyType: 'HASH' }, // Partition key
+ { AttributeName: 'id', KeyType: 'RANGE' } // Sort key
+ ],
+ AttributeDefinitions: [
+ { AttributeName: 'supplierId', AttributeType: 'S' },
+ { AttributeName: 'id', AttributeType: 'S' },
+ ]
+ });
+
+
+export async function createTables(context: DBContext) {
+ const { ddbClient } = context;
+
+ await ddbClient.send(createLetterTableCommand);
+ await ddbClient.send(updateTimeToLiveCommand);
+
+ await ddbClient.send(createMITableCommand);
}
@@ -90,4 +111,8 @@ export async function deleteTables(context: DBContext) {
await ddbClient.send(new DeleteTableCommand({
TableName: 'letters'
}));
+
+ await ddbClient.send(new DeleteTableCommand({
+ TableName: 'management-info'
+ }));
}
diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts
index cd7da96f..6e336468 100644
--- a/internal/datastore/src/__test__/letter-repository.test.ts
+++ b/internal/datastore/src/__test__/letter-repository.test.ts
@@ -307,7 +307,7 @@ describe('LetterRepository', () => {
const mockSend = jest.fn().mockResolvedValue({ Items: null });
const mockDdbClient = { send: mockSend } as any;
- const repo = new LetterRepository(mockDdbClient, { debug: jest.fn() } as any, { lettersTableName: 'letters', ttlHours: 1 });
+ const repo = new LetterRepository(mockDdbClient, { debug: jest.fn() } as any, { lettersTableName: 'letters', lettersTtlHours: 1 });
const letters = await repo.getLettersBySupplier('supplier1', 'PENDING', 10);
expect(letters).toEqual([]);
diff --git a/internal/datastore/src/__test__/mi-repository.test.ts b/internal/datastore/src/__test__/mi-repository.test.ts
new file mode 100644
index 00000000..b0fa2e39
--- /dev/null
+++ b/internal/datastore/src/__test__/mi-repository.test.ts
@@ -0,0 +1,67 @@
+import { Logger } from "pino";
+import { setupDynamoDBContainer, createTables, DBContext, deleteTables } from "./db";
+import { createTestLogger, LogStream } from "./logs";
+import { MIRepository } from "../mi-repository";
+
+// Database tests can take longer, especially with setup and teardown
+jest.setTimeout(30000);
+
+
+describe('MiRepository', () => {
+ let db: DBContext;
+ let miRepository: MIRepository;
+ let logStream: LogStream;
+ let logger: Logger;
+
+
+ beforeAll(async () => {
+ db = await setupDynamoDBContainer();
+ });
+
+ beforeEach(async () => {
+ await createTables(db);
+ (
+ { logStream, logger } = createTestLogger()
+ );
+
+ miRepository = new MIRepository(db.docClient, logger, db.config);
+ });
+
+ afterEach(async () => {
+ await deleteTables(db);
+ jest.useRealTimers();
+ });
+
+ afterAll(async () => {
+ await db.container.stop();
+ });
+
+ describe('putMi', () => {
+
+ it('creates a letter with id and timestamps', async () => {
+
+ jest.useFakeTimers();
+ // Month is zero-indexed in JS Date
+ jest.setSystemTime(new Date(2020, 1, 1));
+ const mi = {
+ specificationId: 'spec1',
+ supplierId: 'supplier1',
+ groupId:'group1',
+ lineItem: 'item1',
+ quantity: 12,
+ timestamp: new Date().toISOString(),
+ stockRemaining: 0
+ };
+
+ const persistedMi = await(miRepository.putMI(mi));
+
+ expect(persistedMi).toEqual(expect.objectContaining({
+ id: expect.any(String),
+ createdAt: '2020-02-01T00:00:00.000Z',
+ updatedAt: '2020-02-01T00:00:00.000Z',
+ ttl: 1580518800, // 2020-02-01T00:01:00.000Z, seconds since epoch
+ ...mi
+ }));
+ });
+ });
+});
diff --git a/internal/datastore/src/config.ts b/internal/datastore/src/config.ts
index ec92d7ad..92081caf 100644
--- a/internal/datastore/src/config.ts
+++ b/internal/datastore/src/config.ts
@@ -2,5 +2,7 @@ export type DatastoreConfig = {
region: string,
endpoint?: string,
lettersTableName: string,
- ttlHours: number
+ miTableName: string,
+ lettersTtlHours: number,
+ miTtlHours: number
}
diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts
index 3c0ebfaf..20f80e92 100644
--- a/internal/datastore/src/index.ts
+++ b/internal/datastore/src/index.ts
@@ -1,3 +1,4 @@
export * from './types';
+export * from './mi-repository';
export * from './letter-repository';
export * from './types';
diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts
index 2afe0792..05bc0e3d 100644
--- a/internal/datastore/src/letter-repository.ts
+++ b/internal/datastore/src/letter-repository.ts
@@ -23,7 +23,7 @@ const defaultPagingOptions = {
export type LetterRepositoryConfig = {
lettersTableName: string,
- ttlHours: number
+ lettersTtlHours: number
}
export class LetterRepository {
@@ -37,7 +37,7 @@ export class LetterRepository {
...letter,
supplierStatus: `${letter.supplierId}#${letter.status}`,
supplierStatusSk: new Date().toISOString(),
- ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours)
+ ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours)
};
try {
await this.ddbClient.send(new PutCommand({
@@ -68,7 +68,7 @@ export class LetterRepository {
...letter,
supplierStatus: `${letter.supplierId}#${letter.status}`,
supplierStatusSk: Date.now().toString(),
- ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours)
+ ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours)
});
if (lettersDb.length === 25 || i === letters.length - 1) {
@@ -143,7 +143,7 @@ export class LetterRepository {
':status': letterToUpdate.status,
':updatedAt': new Date().toISOString(),
':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
- ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.ttlHours)
+ ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours)
};
if (letterToUpdate.reasonCode)
diff --git a/internal/datastore/src/mi-repository.ts b/internal/datastore/src/mi-repository.ts
new file mode 100644
index 00000000..c3d1fd35
--- /dev/null
+++ b/internal/datastore/src/mi-repository.ts
@@ -0,0 +1,38 @@
+import {
+ DynamoDBDocumentClient,
+ PutCommand
+} from '@aws-sdk/lib-dynamodb';
+import { MI, MISchema } from './types';
+import { Logger } from 'pino';
+import { v4 as uuidv4 } from 'uuid';
+
+export type MIRepositoryConfig = {
+ miTableName: string,
+ miTtlHours: number
+};
+
+export class MIRepository {
+ constructor(readonly ddbClient: DynamoDBDocumentClient,
+ readonly log: Logger,
+ readonly config: MIRepositoryConfig) {
+ }
+
+ async putMI(mi: Omit): Promise {
+
+ const now = new Date().toISOString();
+ const miDb = {
+ ...mi,
+ id: uuidv4(),
+ createdAt: now,
+ updatedAt: now,
+ ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.miTtlHours)
+ };
+
+ await this.ddbClient.send(new PutCommand({
+ TableName: this.config.miTableName,
+ Item: miDb
+ }));
+
+ return MISchema.parse(miDb);
+ }
+};
diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts
index 036d86e0..88c18f36 100644
--- a/internal/datastore/src/types.ts
+++ b/internal/datastore/src/types.ts
@@ -46,16 +46,22 @@ export const LetterSchema = LetterSchemaBase.extend({
export type Letter = z.infer;
export type LetterBase = z.infer;
-export const MISchema = z.object({
+export const MISchemaBase = z.object({
id: z.string(),
- supplierId: idRef(SupplierSchema),
- specificationId: z.string(),
- groupId: z.string(),
lineItem: z.string(),
+ timestamp: z.string(),
quantity: z.number(),
- stockRemaining: z.number(),
+ specificationId: z.string().optional(),
+ groupId: z.string().optional(),
+ stockRemaining: z.number().optional()
+});
+
+export const MISchema = MISchemaBase.extend({
+ supplierId: idRef(SupplierSchema),
createdAt: z.string(),
- updatedAt: z.string()
+ updatedAt: z.string(),
+ ttl: z.int(),
}).describe('MI');
export type MI = z.infer;
+export type MIBase = z.infer;
diff --git a/lambdas/api-handler/src/config/__tests__/deps.test.ts b/lambdas/api-handler/src/config/__tests__/deps.test.ts
index 2028db88..3e2911a9 100644
--- a/lambdas/api-handler/src/config/__tests__/deps.test.ts
+++ b/lambdas/api-handler/src/config/__tests__/deps.test.ts
@@ -2,6 +2,17 @@
import type { Deps } from '../deps';
describe('createDependenciesContainer', () => {
+
+ const env = {
+ LETTERS_TABLE_NAME: 'LettersTable',
+ LETTER_TTL_HOURS: 12960,
+ MI_TABLE_NAME: 'MITable',
+ MI_TTL_HOURS: 2160,
+ SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
+ APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
+ DOWNLOAD_URL_TTL_SECONDS: 60
+ };
+
beforeEach(() => {
jest.clearAllMocks();
jest.resetModules();
@@ -24,25 +35,21 @@ describe('createDependenciesContainer', () => {
// Repo client
jest.mock('../../../../../internal/datastore', () => ({
LetterRepository: jest.fn(),
+ MIRepository: jest.fn(),
}));
// Env
- jest.mock('../env', () => ({
- envVars: {
- LETTERS_TABLE_NAME: 'LettersTable',
- LETTER_TTL_HOURS: 12960,
- SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
- APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
- DOWNLOAD_URL_TTL_SECONDS: 60
- },
- }));
+ jest.mock('../env', () => ({envVars: env}));
});
test('constructs deps and wires repository config correctly', async () => {
// get current mock instances
const { S3Client } = jest.requireMock('@aws-sdk/client-s3') as { S3Client: jest.Mock };
const pinoMock = jest.requireMock('pino') as { default: jest.Mock };
- const { LetterRepository } = jest.requireMock('../../../../../internal/datastore') as { LetterRepository: jest.Mock };
+ const { LetterRepository, MIRepository } = jest.requireMock('../../../../../internal/datastore') as {
+ LetterRepository: jest.Mock,
+ MIRepository: jest.Mock
+ };
const { createDependenciesContainer } = require('../deps');
const deps: Deps = createDependenciesContainer();
@@ -51,18 +58,19 @@ describe('createDependenciesContainer', () => {
expect(pinoMock.default).toHaveBeenCalledTimes(1);
expect(LetterRepository).toHaveBeenCalledTimes(1);
- const repoCtorArgs = (LetterRepository as jest.Mock).mock.calls[0];
- expect(repoCtorArgs[2]).toEqual({
+ const letterRepoCtorArgs = (LetterRepository as jest.Mock).mock.calls[0];
+ expect(letterRepoCtorArgs[2]).toEqual({
lettersTableName: 'LettersTable',
- ttlHours: 12960
+ lettersTtlHours: 12960
});
- expect(deps.env).toEqual({
- LETTERS_TABLE_NAME: 'LettersTable',
- LETTER_TTL_HOURS: 12960,
- SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
- APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
- DOWNLOAD_URL_TTL_SECONDS: 60
+ expect(MIRepository).toHaveBeenCalledTimes(1);
+ const miRepoCtorArgs = (MIRepository as jest.Mock).mock.calls[0];
+ expect(miRepoCtorArgs[2]).toEqual({
+ miTableName: 'MITable',
+ miTtlHours: 2160
});
+
+ expect(deps.env).toEqual(env);
});
});
diff --git a/lambdas/api-handler/src/config/__tests__/env.test.ts b/lambdas/api-handler/src/config/__tests__/env.test.ts
index c24205ab..2399c941 100644
--- a/lambdas/api-handler/src/config/__tests__/env.test.ts
+++ b/lambdas/api-handler/src/config/__tests__/env.test.ts
@@ -16,7 +16,9 @@ describe('lambdaEnv', () => {
process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id';
process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id';
process.env.LETTERS_TABLE_NAME = 'letters-table';
+ process.env.MI_TABLE_NAME = 'mi-table';
process.env.LETTER_TTL_HOURS = '12960';
+ process.env.MI_TTL_HOURS = '2160';
process.env.DOWNLOAD_URL_TTL_SECONDS = '60';
process.env.MAX_LIMIT = '2500';
@@ -26,7 +28,9 @@ describe('lambdaEnv', () => {
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
LETTERS_TABLE_NAME: 'letters-table',
+ MI_TABLE_NAME: 'mi-table',
LETTER_TTL_HOURS: 12960,
+ MI_TTL_HOURS: 2160,
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: 2500,
});
@@ -36,7 +40,9 @@ describe('lambdaEnv', () => {
process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id';
process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id';
process.env.LETTERS_TABLE_NAME = undefined; // simulate missing var
+ process.env.MI_TABLE_NAME = 'mi-table';
process.env.LETTER_TTL_HOURS = '12960';
+ process.env.MI_TTL_HOURS = '2160';
process.env.DOWNLOAD_URL_TTL_SECONDS = '60';
expect(() => require('../env')).toThrow(ZodError);
@@ -46,7 +52,9 @@ describe('lambdaEnv', () => {
process.env.SUPPLIER_ID_HEADER = 'nhsd-supplier-id';
process.env.APIM_CORRELATION_HEADER = 'nhsd-correlation-id';
process.env.LETTERS_TABLE_NAME = 'letters-table';
+ process.env.MI_TABLE_NAME = 'mi-table';
process.env.LETTER_TTL_HOURS = '12960';
+ process.env.MI_TTL_HOURS = '2160';
process.env.DOWNLOAD_URL_TTL_SECONDS = '60';
const { envVars } = require('../env');
@@ -55,7 +63,9 @@ describe('lambdaEnv', () => {
SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
LETTERS_TABLE_NAME: 'letters-table',
+ MI_TABLE_NAME: 'mi-table',
LETTER_TTL_HOURS: 12960,
+ MI_TTL_HOURS: 2160,
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: undefined
});
diff --git a/lambdas/api-handler/src/config/deps.ts b/lambdas/api-handler/src/config/deps.ts
index 6f9c3e84..1942bc6c 100644
--- a/lambdas/api-handler/src/config/deps.ts
+++ b/lambdas/api-handler/src/config/deps.ts
@@ -2,34 +2,53 @@ import { S3Client } from "@aws-sdk/client-s3";
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import pino from 'pino';
-import { LetterRepository } from '../../../../internal/datastore';
+import { LetterRepository, MIRepository } from '../../../../internal/datastore';
import { envVars, EnvVars } from "../config/env";
export type Deps = {
s3Client: S3Client;
letterRepo: LetterRepository;
- logger: pino.Logger,
+ miRepo: MIRepository;
+ logger: pino.Logger;
env: EnvVars
};
-function createLetterRepository(log: pino.Logger, envVars: EnvVars): LetterRepository {
+function createDocumentClient(): DynamoDBDocumentClient {
+ const ddbClient = new DynamoDBClient({});
+ return DynamoDBDocumentClient.from(ddbClient);
+}
+
+function createLetterRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): LetterRepository {
const ddbClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(ddbClient);
const config = {
lettersTableName: envVars.LETTERS_TABLE_NAME,
- ttlHours: envVars.LETTER_TTL_HOURS
+ lettersTtlHours: envVars.LETTER_TTL_HOURS
};
return new LetterRepository(docClient, log, config);
}
+function createMIRepository(documentClient: DynamoDBDocumentClient, log: pino.Logger, envVars: EnvVars): MIRepository {
+ const ddbClient = new DynamoDBClient({});
+ const docClient = DynamoDBDocumentClient.from(ddbClient);
+ const config = {
+ miTableName: envVars.MI_TABLE_NAME,
+ miTtlHours: envVars.MI_TTL_HOURS
+ };
+
+ return new MIRepository(docClient, log, config);
+}
+
export function createDependenciesContainer(): Deps {
const log = pino();
+ const documentClient = createDocumentClient();
return {
s3Client: new S3Client(),
- letterRepo: createLetterRepository(log, envVars),
+ letterRepo: createLetterRepository(documentClient, log, envVars),
+ miRepo: createMIRepository(documentClient, log, envVars),
logger: log,
env: envVars
};
diff --git a/lambdas/api-handler/src/config/env.ts b/lambdas/api-handler/src/config/env.ts
index 5dfe4d27..f77f455e 100644
--- a/lambdas/api-handler/src/config/env.ts
+++ b/lambdas/api-handler/src/config/env.ts
@@ -4,7 +4,9 @@ const EnvVarsSchema = z.object({
SUPPLIER_ID_HEADER: z.string(),
APIM_CORRELATION_HEADER: z.string(),
LETTERS_TABLE_NAME: z.string(),
+ MI_TABLE_NAME: z.string(),
LETTER_TTL_HOURS: z.coerce.number().int(),
+ MI_TTL_HOURS: z.coerce.number().int(),
DOWNLOAD_URL_TTL_SECONDS: z.coerce.number().int(),
MAX_LIMIT: z.coerce.number().int().optional()
});
diff --git a/lambdas/api-handler/src/contracts/errors.ts b/lambdas/api-handler/src/contracts/errors.ts
index f3b3114e..a508739b 100644
--- a/lambdas/api-handler/src/contracts/errors.ts
+++ b/lambdas/api-handler/src/contracts/errors.ts
@@ -34,7 +34,8 @@ export enum ApiErrorDetail {
InvalidRequestLimitNotANumber = 'The limit parameter is not a number',
InvalidRequestLimitNotInRange = 'The limit parameter must be a positive number not greater than %s',
InvalidRequestLimitOnly = "Only 'limit' query parameter is supported",
- InvalidRequestNoRequestId = 'The request does not contain a request id'
+ InvalidRequestNoRequestId = 'The request does not contain a request id',
+ InvalidRequestTimestamp = 'Timestamps should be UTC date/times in ISO8601 format, with a Z suffix'
}
export function buildApiError(params: {
diff --git a/lambdas/api-handler/src/contracts/mi.ts b/lambdas/api-handler/src/contracts/mi.ts
new file mode 100644
index 00000000..9223a636
--- /dev/null
+++ b/lambdas/api-handler/src/contracts/mi.ts
@@ -0,0 +1,27 @@
+import z from "zod";
+import { makeDocumentSchema } from "./json-api";
+
+export const PostMIRequestResourceSchema = z.object({
+ type: z.literal('ManagementInformation'),
+ attributes: z.object({
+ lineItem: z.string(),
+ timestamp: z.string(),
+ quantity: z.number(),
+ specificationId: z.string().optional(),
+ groupId: z.string().optional(),
+ stockRemaining: z.number().optional(),
+ }).strict()
+}).strict();
+
+export const PostMIResponseResourceSchema = z.object({
+ id: z.string(),
+ ...PostMIRequestResourceSchema.shape,
+}).strict();
+
+export const PostMIRequestSchema = makeDocumentSchema(PostMIRequestResourceSchema);
+export const PostMIResponseSchema = makeDocumentSchema(PostMIResponseResourceSchema);
+
+export type PostMIRequest = z.infer;
+export type PostMIResponse = z.infer;
+
+export type IncomingMI = PostMIRequest['data']['attributes'] & {supplierId: string};
diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts
index 5b6509a8..3cbde039 100644
--- a/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts
+++ b/lambdas/api-handler/src/handlers/__tests__/get-letter-data.test.ts
@@ -37,7 +37,7 @@ describe('API Lambda handler', () => {
LETTER_TTL_HOURS: 12960,
DOWNLOAD_URL_TTL_SECONDS: 60
} as unknown as EnvVars
- }
+ } as Deps;
beforeEach(() => {
jest.clearAllMocks();
diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
index 99b01b74..a21325a5 100644
--- a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
+++ b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
@@ -28,7 +28,7 @@ describe('API Lambda handler', () => {
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: 2500
} as unknown as EnvVars
- };
+ } as Deps;
beforeEach(() => {
jest.clearAllMocks();
diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts
index bc000f30..5177f25d 100644
--- a/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts
+++ b/lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts
@@ -38,7 +38,7 @@ describe('API Lambda handler', () => {
DOWNLOAD_URL_TTL_SECONDS: 60,
MAX_LIMIT: 2500
} as unknown as EnvVars
- }
+ } as Deps;
beforeEach(() => {
jest.clearAllMocks();
diff --git a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts
index 8c96ce79..8e43b252 100644
--- a/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts
+++ b/lambdas/api-handler/src/handlers/__tests__/patch-letter.test.ts
@@ -47,17 +47,17 @@ describe('patchLetter API Handler', () => {
});
const mockedDeps: jest.Mocked = {
- s3Client: {} as unknown as S3Client,
- letterRepo: {} as unknown as LetterRepository,
- logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
- env: {
- SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
- APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
- LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME',
- LETTER_TTL_HOURS: 12960,
- DOWNLOAD_URL_TTL_SECONDS: 60
- } as unknown as EnvVars
- }
+ s3Client: {} as unknown as S3Client,
+ letterRepo: {} as unknown as LetterRepository,
+ logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
+ env: {
+ SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
+ APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
+ LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME',
+ LETTER_TTL_HOURS: 12960,
+ DOWNLOAD_URL_TTL_SECONDS: 60
+ } as unknown as EnvVars
+ } as Deps;
it('returns 200 OK with updated resource', async () => {
const event = makeApiGwEvent({
diff --git a/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts
new file mode 100644
index 00000000..d7d942e1
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/__tests__/post-mi.test.ts
@@ -0,0 +1,203 @@
+import { Context } from "aws-lambda";
+import { mockDeep } from "jest-mock-extended";
+import { makeApiGwEvent } from "./utils/test-utils";
+import { PostMIRequest, PostMIResponse } from "../../contracts/mi";
+import * as miService from '../../services/mi-operations';
+import pino from 'pino';
+import { MIRepository } from "../../../../../internal/datastore/src";
+import { Deps } from "../../config/deps";
+import { EnvVars } from "../../config/env";
+import { createPostMIHandler } from "../post-mi";
+
+jest.mock('../../services/mi-operations');
+
+const postMIRequest : PostMIRequest = {
+ data: {
+ type: 'ManagementInformation',
+ attributes: {
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000
+ }
+ }
+};
+const requestBody = JSON.stringify(postMIRequest, null, 2);
+
+ const postMIResponse : PostMIResponse = {
+ data: {
+ id: 'id1',
+ ...postMIRequest.data
+ }
+ };
+
+const mockedPostMIOperation = jest.mocked(miService.postMI);
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+
+describe('postMI API Handler', () => {
+
+ const mockedDeps: jest.Mocked = {
+ miRepo: {} as unknown as MIRepository,
+ logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
+ env: {
+ SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
+ APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
+ DOWNLOAD_URL_TTL_SECONDS: 1
+ } as unknown as EnvVars
+ } as Deps;
+
+
+ it('returns 200 OK with updated resource', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: requestBody,
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+
+ mockedPostMIOperation.mockResolvedValue(postMIResponse);
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual({
+ statusCode: 201,
+ body: JSON.stringify(postMIResponse, null, 2)
+ });
+ });
+
+
+ it('rejects invalid timestamps', async() => {
+ const modifiedRequest = JSON.parse(requestBody);
+ modifiedRequest['data']['attributes']['timestamp'] = '2025-02-31T00:00:00Z';
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: JSON.stringify(modifiedRequest),
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 400
+ }));
+ });
+
+ it('returns 400 Bad Request when there is no body', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 400
+ }));
+ });
+
+
+ it('returns 500 Internal Error when error is thrown by service', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: requestBody,
+ pathParameters: {id: 'id1'},
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+ mockedPostMIOperation.mockRejectedValue(new Error());
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 500
+ }));
+ });
+
+ it('returns 500 Bad Request when supplier id is missing', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: requestBody,
+ headers: {'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 500
+ }));
+ });
+
+ it('returns 500 Internal Server Error when correlation id is missing', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: requestBody,
+ headers: {'nhsd-supplier-id': 'supplier1', 'x-request-id': 'requestId'}
+ });
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 500
+ }));
+ });
+
+ it('returns 400 Bad Request when request does not have correct shape', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: '{"test": "test"}',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 400
+ }));
+ });
+
+ it('returns 400 Bad Request when request body is not json', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: '{#invalidJSON',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 400
+ }));
+ });
+
+ it('returns 500 Internal Server Error when parsing fails', async () => {
+ const event = makeApiGwEvent({
+ path: '/mi',
+ body: requestBody,
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}
+ });
+ const spy = jest.spyOn(JSON, 'parse').mockImplementation(() => {
+ throw 'Unexpected error';
+ })
+
+ const postMI = createPostMIHandler(mockedDeps);
+ const result = await postMI(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 500
+ }));
+
+ spy.mockRestore();
+ });
+});
diff --git a/lambdas/api-handler/src/handlers/post-mi.ts b/lambdas/api-handler/src/handlers/post-mi.ts
new file mode 100644
index 00000000..526f849b
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/post-mi.ts
@@ -0,0 +1,47 @@
+import { APIGatewayProxyHandler } from "aws-lambda";
+import { postMI as postMIOperation } from '../services/mi-operations';
+import { ApiErrorDetail } from "../contracts/errors";
+import { ValidationError } from "../errors";
+import { mapErrorToResponse } from "../mappers/error-mapper";
+import { assertNotEmpty, validateCommonHeaders, validateIso8601Timestamp } from "../utils/validation";
+import { PostMIRequest, PostMIRequestSchema } from "../contracts/mi";
+import { mapToMI } from "../mappers/mi-mapper";
+import { Deps } from "../config/deps";
+
+export function createPostMIHandler(deps: Deps): APIGatewayProxyHandler {
+
+ return async (event) => {
+
+ const commonHeadersResult = validateCommonHeaders(event.headers, deps);
+
+ if (!commonHeadersResult.ok) {
+ return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger);
+ }
+
+ try {
+ const body = assertNotEmpty(event.body, new ValidationError(ApiErrorDetail.InvalidRequestMissingBody));
+
+ let postMIRequest: PostMIRequest;
+
+ try {
+ postMIRequest = PostMIRequestSchema.parse(JSON.parse(body));
+ } catch (error) {
+ if (error instanceof Error) {
+ throw new ValidationError(ApiErrorDetail.InvalidRequestBody, { cause: error});
+ }
+ else throw error;
+ }
+ validateIso8601Timestamp(postMIRequest.data.attributes.timestamp);
+
+ const result = await postMIOperation(mapToMI(postMIRequest, commonHeadersResult.value.supplierId), deps.miRepo);
+
+ return {
+ statusCode: 201,
+ body: JSON.stringify(result, null, 2)
+ };
+
+ } catch (error) {
+ return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger);
+ }
+ }
+};
diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts
index bca91bc9..38328703 100644
--- a/lambdas/api-handler/src/index.ts
+++ b/lambdas/api-handler/src/index.ts
@@ -3,6 +3,7 @@ import { createGetLetterHandler } from "./handlers/get-letter";
import { createGetLetterDataHandler } from "./handlers/get-letter-data";
import { createGetLettersHandler } from "./handlers/get-letters";
import { createPatchLetterHandler } from "./handlers/patch-letter";
+import { createPostMIHandler } from "./handlers/post-mi";
const container = createDependenciesContainer();
@@ -10,3 +11,4 @@ export const getLetter = createGetLetterHandler(container);
export const getLetterData = createGetLetterDataHandler(container);
export const getLetters = createGetLettersHandler(container);
export const patchLetter = createPatchLetterHandler(container);
+export const postMI = createPostMIHandler(container);
diff --git a/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts
new file mode 100644
index 00000000..be1a55c1
--- /dev/null
+++ b/lambdas/api-handler/src/mappers/__tests__/mi-mapper.test.ts
@@ -0,0 +1,62 @@
+import { MIBase } from "../../../../../internal/datastore/src";
+import { IncomingMI, PostMIRequest } from "../../contracts/mi";
+import { mapToMI, mapToPostMIResponse } from "../mi-mapper";
+
+describe ('mi-mapper', () => {
+ it('maps a PostMIRequest to an IncomingMI object', async () => {
+ const postMIRequest: PostMIRequest = {
+ data: {
+ type: 'ManagementInformation',
+ attributes: {
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000
+ }
+ }
+ };
+
+ const result: IncomingMI = mapToMI(postMIRequest, 'supplier1');
+
+ expect(result).toEqual({
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000,
+ supplierId: 'supplier1'
+ });
+ });
+
+ it('maps an internal MIBase object to a PostMIResponse', async() => {
+ const mi: MIBase = {
+ id: 'id1',
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000
+ };
+
+ const result = mapToPostMIResponse(mi);
+
+ expect(result).toEqual({
+ data: {
+ id: 'id1',
+ type: 'ManagementInformation',
+ attributes: {
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000
+ }
+ }
+ });
+ });
+});
diff --git a/lambdas/api-handler/src/mappers/mi-mapper.ts b/lambdas/api-handler/src/mappers/mi-mapper.ts
new file mode 100644
index 00000000..83848e1a
--- /dev/null
+++ b/lambdas/api-handler/src/mappers/mi-mapper.ts
@@ -0,0 +1,26 @@
+import { MIBase } from "../../../../internal/datastore/src";
+import { IncomingMI, PostMIRequest as PostMIRequest, PostMIResponse, PostMIResponseSchema } from "../contracts/mi";
+
+export function mapToMI(request: PostMIRequest, supplierId: string): IncomingMI {
+ return {
+ supplierId: supplierId,
+ ...request.data.attributes
+ };
+}
+
+export function mapToPostMIResponse(mi: MIBase): PostMIResponse {
+ return PostMIResponseSchema.parse({
+ data: {
+ id: mi.id,
+ type: 'ManagementInformation',
+ attributes: {
+ lineItem: mi.lineItem,
+ timestamp: mi.timestamp,
+ quantity: mi.quantity,
+ specificationId: mi.specificationId,
+ groupId: mi.groupId,
+ stockRemaining: mi.stockRemaining
+ }
+ }
+ });
+}
diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
index 7c24c958..284931e8 100644
--- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
+++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
@@ -175,7 +175,7 @@ describe('getLetterDataUrl function', () => {
APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
DOWNLOAD_URL_TTL_SECONDS: 60
};
- const deps: Deps = { s3Client, letterRepo, logger, env };
+ const deps: Deps = { s3Client, letterRepo, logger, env } as Deps;
it('should return pre signed url successfully', async () => {
diff --git a/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts b/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts
new file mode 100644
index 00000000..6dd245de
--- /dev/null
+++ b/lambdas/api-handler/src/services/__tests__/mi-operations.test.ts
@@ -0,0 +1,40 @@
+import { IncomingMI } from "../../contracts/mi";
+import { postMI } from "../mi-operations";
+
+describe('postMI function', () => {
+
+ const incomingMi: IncomingMI = {
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000,
+ supplierId: 'supplier1'
+ };
+
+ it('creates the MI in the repository', async () => {
+ const persistedMi = {id: 'id1', ...incomingMi};
+
+ const mockRepo = {
+ putMI: jest.fn().mockResolvedValue(persistedMi)
+ };
+
+ const result = await postMI(incomingMi, mockRepo as any);
+
+ expect(result).toEqual({
+ data: {
+ id: 'id1',
+ type: 'ManagementInformation',
+ attributes: {
+ lineItem: 'envelope-business-standard',
+ timestamp: '2023-11-17T14:27:51.413Z',
+ quantity: 22,
+ specificationId: 'spec1',
+ groupId: 'group1',
+ stockRemaining: 20000
+ }
+ }
+ });
+ });
+});
diff --git a/lambdas/api-handler/src/services/mi-operations.ts b/lambdas/api-handler/src/services/mi-operations.ts
new file mode 100644
index 00000000..2c574b67
--- /dev/null
+++ b/lambdas/api-handler/src/services/mi-operations.ts
@@ -0,0 +1,7 @@
+import { MIRepository } from "../../../../internal/datastore/src/mi-repository";
+import { IncomingMI, PostMIResponse } from "../contracts/mi";
+import { mapToPostMIResponse } from "../mappers/mi-mapper";
+
+export const postMI = async (incomingMi: IncomingMI, miRepo: MIRepository): Promise => {
+ return mapToPostMIResponse(await miRepo.putMI(incomingMi));
+}
diff --git a/lambdas/api-handler/src/utils/__tests__/validation.test.ts b/lambdas/api-handler/src/utils/__tests__/validation.test.ts
index 8725cabb..1a95330c 100644
--- a/lambdas/api-handler/src/utils/__tests__/validation.test.ts
+++ b/lambdas/api-handler/src/utils/__tests__/validation.test.ts
@@ -1,4 +1,5 @@
-import { assertNotEmpty, lowerCaseKeys } from "../validation";
+import { ValidationError } from "../../errors";
+import { assertNotEmpty, lowerCaseKeys, validateIso8601Timestamp } from "../validation";
describe("assertNotEmpty", () => {
const error = new Error();
@@ -65,3 +66,16 @@ describe("lowerCaseKeys", () => {
expect(result).toEqual({});
});
});
+
+describe('validateIso8601Timestamp', () => {
+ it.each([['2025-10-16T00:00:00.000Z'], ['2025-10-16T00:00:00Z'], ['2025-10-16T00:00:00.0Z'], ['2025-10-16T00:00:00.999999Z']])
+ ('permits valid timestamps', (timestamp: string) => {
+ validateIso8601Timestamp(timestamp);
+ });
+
+ it.each([['not a date string'], ['2025-10-16T00:00:00'], ['2025-16-10T00:00:00Z'], ['2025-02-31T00:00:00Z']])
+ ('rejects invalid timestamps', (timestamp: string) => {
+ expect(() => validateIso8601Timestamp(timestamp)).toThrow(ValidationError);
+ });
+
+});
diff --git a/lambdas/api-handler/src/utils/validation.ts b/lambdas/api-handler/src/utils/validation.ts
index 318131c4..f86b4fff 100644
--- a/lambdas/api-handler/src/utils/validation.ts
+++ b/lambdas/api-handler/src/utils/validation.ts
@@ -60,3 +60,27 @@ export function validateCommonHeaders(headers: APIGatewayProxyEventHeaders, deps
return { ok: true, value: { correlationId, supplierId } };
}
+
+export function validateIso8601Timestamp(timestamp: string) {
+
+ function normalisePrecision([_, mainPart, fractionalPart='.000']: string[]) : string {
+ if (fractionalPart.length < 4) {
+ return mainPart + fractionalPart + '0'.repeat(4 - fractionalPart.length) + 'Z';
+ } else {
+ return mainPart + fractionalPart.slice(0, 4) + 'Z';
+ }
+ }
+
+ const groups = timestamp.match(/(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(.\d+)?Z/);
+ if (!groups) {
+ throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp);
+ }
+ const date = new Date(timestamp);
+ // An invalid month (e.g. '2025-16-10T00:00:00Z') will result in new Date(timestamp).valueOf() returning NaN.
+ // An invalid day of month (e.g. '2025-02-31T00:00:00Z') will roll over into the following month, but we can
+ // detect that by comparing date.toISOString() with the original timestamp string. We need to normalise the
+ // original string to millisecond precision to make this work.
+ if (Number.isNaN(new Date(timestamp).valueOf()) || date.toISOString() != normalisePrecision(groups)) {
+ throw new ValidationError(ApiErrorDetail.InvalidRequestTimestamp);
+ }
+}