diff --git a/.github/actions/acceptance-tests/action.yaml b/.github/actions/acceptance-tests/action.yaml index 9113e037..f734055c 100644 --- a/.github/actions/acceptance-tests/action.yaml +++ b/.github/actions/acceptance-tests/action.yaml @@ -30,6 +30,11 @@ runs: run: | npm ci + - name: "Generate dependencies" + shell: bash + run: | + npm run generate-dependencies + - name: Run test - ${{ inputs.testType }} shell: bash run: | diff --git a/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf b/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf index 4652576e..57e80e60 100644 --- a/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf +++ b/infrastructure/terraform/components/dl/cloudwatch_event_rule_mesh_inbox_message_downloaded.tf @@ -8,9 +8,6 @@ resource "aws_cloudwatch_event_rule" "mesh_inbox_message_downloaded" { "type" : [ "uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1" ], - "dataschemaversion" : [{ - "prefix" : "1." - }] } }) } diff --git a/lambdas/ttl-create-lambda/package.json b/lambdas/ttl-create-lambda/package.json index 1f26c58d..d0031751 100644 --- a/lambdas/ttl-create-lambda/package.json +++ b/lambdas/ttl-create-lambda/package.json @@ -1,8 +1,8 @@ { "dependencies": { "@aws-sdk/lib-dynamodb": "^3.908.0", - "utils": "^0.0.1", - "zod": "^4.1.12" + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", diff --git a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts index 939e086f..cc87b11b 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/apis/sqs-trigger-lambda.test.ts @@ -1,6 +1,10 @@ import { createHandler } from 'apis/sqs-trigger-lambda'; import type { SQSEvent } from 'aws-lambda'; -import { $TtlItemBusEvent, TtlItemBusEvent } from 'utils'; +import { + ItemEnqueued, + MESHInboxMessageDownloaded, +} from 'digital-letters-events'; +import itemEnqueuedValidator from 'digital-letters-events/ItemEnqueued.js'; import { randomUUID } from 'node:crypto'; jest.mock('node:crypto', () => ({ @@ -18,35 +22,45 @@ describe('createHandler', () => { let logger: any; let handler: any; - const validItem: TtlItemBusEvent = { - detail: { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440001', - specversion: '1.0', - source: - '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.sent.v1', - time: '2023-06-20T12:00:00Z', - recordedtime: '2023-06-20T12:00:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', - severitytext: 'INFO', - data: { - messageUri: 'https://example.com/ttl/resource', - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, + const messageDownloadedEvent: MESHInboxMessageDownloaded = { + id: '550e8400-e29b-41d4-a716-446655440001', + specversion: '1.0', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', + time: '2023-06-20T12:00:00Z', + recordedtime: '2023-06-20T12:00:00.250Z', + severitynumber: 2, + traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', + severitytext: 'INFO', + data: { + messageUri: 'https://example.com/ttl/resource', + messageReference: 'ref1', + senderId: 'sender1', }, }; + const eventBusEvent = { + detail: messageDownloadedEvent, + }; + + const itemEnqueuedEvent: ItemEnqueued = { + ...messageDownloadedEvent, + id: '550e8400-e29b-41d4-a716-446655440001', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/queue', + type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', + time: '2023-06-20T12:00:00.250Z', + recordedtime: '2023-06-20T12:00:00.250Z', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json', + }; + beforeEach(() => { createTtl = { send: jest.fn() }; eventPublisher = { sendEvents: jest.fn().mockResolvedValue([]) }; @@ -55,27 +69,24 @@ describe('createHandler', () => { }); it('processes a valid SQS event and returns success', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: true, data: validItem }); createTtl.send.mockResolvedValue('sent'); const event: SQSEvent = { - Records: [{ body: JSON.stringify(validItem), messageId: 'msg1' }], + Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], } as any; const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(createTtl.send).toHaveBeenCalledWith(validItem.detail); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - ]); + expect(createTtl.send).toHaveBeenCalledWith(messageDownloadedEvent); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent], + itemEnqueuedValidator, + ); + + const publishedEvent = eventPublisher.sendEvents.mock.lastCall?.[0]; + expect(publishedEvent).toHaveLength(1); + expect(itemEnqueuedValidator(publishedEvent?.[0])).toBeTruthy(); + expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -85,10 +96,6 @@ describe('createHandler', () => { }); it('handles parse failure and logs error', async () => { - const zodError = { errors: [] } as any; - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: false, error: zodError }); const event: SQSEvent = { Records: [{ body: '{}', messageId: 'msg2' }], } as any; @@ -110,12 +117,9 @@ describe('createHandler', () => { }); it('handles createTtl.send failure', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: true, data: validItem }); createTtl.send.mockResolvedValue('failed'); const event: SQSEvent = { - Records: [{ body: JSON.stringify(validItem), messageId: 'msg3' }], + Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg3' }], } as any; const res = await handler(event); @@ -130,11 +134,9 @@ describe('createHandler', () => { }); it('handles thrown error and logs', async () => { - jest.spyOn($TtlItemBusEvent, 'safeParse').mockImplementation(() => { - throw new Error('bad json'); - }); + createTtl.send.mockRejectedValue(new Error('TTL service error')); const event: SQSEvent = { - Records: [{ body: '{}', messageId: 'msg4' }], + Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg4' }], } as any; const res = await handler(event); @@ -181,15 +183,12 @@ describe('createHandler', () => { }); it('processes multiple successful events and sends them as a batch', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: true, data: validItem }); createTtl.send.mockResolvedValue('sent'); const sqsEvent: SQSEvent = { Records: [ - { body: JSON.stringify(validItem), messageId: 'msg1' }, - { body: JSON.stringify(validItem), messageId: 'msg2' }, - { body: JSON.stringify(validItem), messageId: 'msg3' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg2' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, ], } as any; @@ -197,29 +196,10 @@ describe('createHandler', () => { expect(res.batchItemFailures).toEqual([]); expect(createTtl.send).toHaveBeenCalledTimes(3); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent, itemEnqueuedEvent, itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 0, @@ -229,39 +209,24 @@ describe('createHandler', () => { }); it('handles partial event publishing failures and logs warning', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: true, data: validItem }); createTtl.send.mockResolvedValue('sent'); - const failedEvents = [validItem]; + const failedEvents = [messageDownloadedEvent]; eventPublisher.sendEvents.mockResolvedValue(failedEvents); const event: SQSEvent = { Records: [ - { body: JSON.stringify(validItem), messageId: 'msg1' }, - { body: JSON.stringify(validItem), messageId: 'msg2' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg2' }, ], } as any; const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent, itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.warn).toHaveBeenCalledWith({ description: 'Some events failed to publish', failedCount: 1, @@ -270,29 +235,21 @@ describe('createHandler', () => { }); it('handles event publishing exception and logs warning', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: true, data: validItem }); createTtl.send.mockResolvedValue('sent'); const publishError = new Error('EventBridge error'); eventPublisher.sendEvents.mockRejectedValue(publishError); const event: SQSEvent = { - Records: [{ body: JSON.stringify(validItem), messageId: 'msg1' }], + Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], } as any; const res = await handler(event); expect(res.batchItemFailures).toEqual([]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.warn).toHaveBeenCalledWith({ err: publishError, description: 'Failed to send events to EventBridge', @@ -301,13 +258,10 @@ describe('createHandler', () => { }); it('does not call eventPublisher when no successful events', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValue({ success: true, data: validItem }); createTtl.send.mockResolvedValue('failed'); const event: SQSEvent = { - Records: [{ body: JSON.stringify(validItem), messageId: 'msg1' }], + Records: [{ body: JSON.stringify(eventBusEvent), messageId: 'msg1' }], } as any; const res = await handler(event); @@ -323,20 +277,15 @@ describe('createHandler', () => { }); it('handles mixed success and failure scenarios', async () => { - jest - .spyOn($TtlItemBusEvent, 'safeParse') - .mockReturnValueOnce({ success: true, data: validItem }) - .mockReturnValueOnce({ success: false, error: { errors: [] } as any }) - .mockReturnValueOnce({ success: true, data: validItem }); createTtl.send .mockResolvedValueOnce('sent') .mockResolvedValueOnce('failed'); const event: SQSEvent = { Records: [ - { body: JSON.stringify(validItem), messageId: 'msg1' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg1' }, { body: '{}', messageId: 'msg2' }, - { body: JSON.stringify(validItem), messageId: 'msg3' }, + { body: JSON.stringify(eventBusEvent), messageId: 'msg3' }, ], } as any; @@ -346,15 +295,10 @@ describe('createHandler', () => { { itemIdentifier: 'msg2' }, { itemIdentifier: 'msg3' }, ]); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - { - ...validItem.detail, - id: '550e8400-e29b-41d4-a716-446655440001', - time: '2023-06-20T12:00:00.250Z', - recordedtime: '2023-06-20T12:00:00.250Z', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', - }, - ]); + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [itemEnqueuedEvent], + itemEnqueuedValidator, + ); expect(logger.info).toHaveBeenCalledWith({ description: 'Processed SQS Event.', failed: 2, diff --git a/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts b/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts index 6f8be6f3..3998ddad 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/app/create-ttl.test.ts @@ -1,31 +1,27 @@ import { CreateTtl } from 'app/create-ttl'; import { TtlRepository } from 'infra/ttl-repository'; -import { TtlItemEvent } from 'utils'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; describe('CreateTtl', () => { let repo: jest.Mocked; let logger: any; let createTtl: CreateTtl; - const item: TtlItemEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', + const item: MESHInboxMessageDownloaded = { id: '550e8400-e29b-41d4-a716-446655440001', specversion: '1.0', source: '/nhs/england/notify/production/primary/data-plane/digital-letters', subject: 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', + type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', time: '2023-06-20T12:00:00Z', recordedtime: '2023-06-20T12:00:00.250Z', severitynumber: 2, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', datacontenttype: 'application/json', dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', severitytext: 'INFO', data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', messageReference: 'ref1', senderId: 'sender1', messageUri: 'https://example.com/ttl/resource', diff --git a/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts b/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts index 05c7599e..dc80be6d 100644 --- a/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts +++ b/lambdas/ttl-create-lambda/src/__tests__/infra/ttl-repository.test.ts @@ -1,6 +1,6 @@ import { PutCommand } from '@aws-sdk/lib-dynamodb'; import { TtlRepository } from 'infra/ttl-repository'; -import { TtlItemEvent } from 'utils'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; jest.useFakeTimers(); @@ -15,26 +15,22 @@ describe('TtlRepository', () => { let repo: TtlRepository; const tableName = 'table'; const ttlWaitTimeHours = 24; - const item: TtlItemEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', + const item: MESHInboxMessageDownloaded = { id: '550e8400-e29b-41d4-a716-446655440001', specversion: '1.0', source: '/nhs/england/notify/production/primary/data-plane/digital-letters', subject: 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', + type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', time: '2023-06-20T12:00:00Z', recordedtime: '2023-06-20T12:00:00.250Z', severitynumber: 2, traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', datacontenttype: 'application/json', dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', severitytext: 'INFO', data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', messageReference: 'ref1', senderId: 'sender1', messageUri: 'https://example.com/ttl/resource', diff --git a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts index 4b8124a1..d18219c7 100644 --- a/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts +++ b/lambdas/ttl-create-lambda/src/apis/sqs-trigger-lambda.ts @@ -5,11 +5,17 @@ import type { } from 'aws-lambda'; import { randomUUID } from 'node:crypto'; import type { CreateTtl, CreateTtlOutcome } from 'app/create-ttl'; -import { $TtlItemBusEvent, EventPublisher, Logger, TtlItemEvent } from 'utils'; +import { EventPublisher, Logger } from 'utils'; +import itemEnqueuedValidator from 'digital-letters-events/ItemEnqueued.js'; +import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; +import { + ItemEnqueued, + MESHInboxMessageDownloaded, +} from 'digital-letters-events'; interface ProcessingResult { result: CreateTtlOutcome; - item?: TtlItemEvent; + item?: MESHInboxMessageDownloaded; } interface CreateHandlerDependencies { @@ -25,34 +31,33 @@ export const createHandler = ({ }: CreateHandlerDependencies) => async function handler(sqsEvent: SQSEvent): Promise { const batchItemFailures: SQSBatchItemFailure[] = []; - const successfulEvents: TtlItemEvent[] = []; const promises = sqsEvent.Records.map( async ({ body, messageId }): Promise => { try { - const { - data: item, - error: parseError, - success: parseSuccess, - } = $TtlItemBusEvent.safeParse(JSON.parse(body)); + const sqsEventBody = JSON.parse(body); + const sqsEventDetail = sqsEventBody.detail; - if (!parseSuccess) { + const isEventValid = messageDownloadedValidator(sqsEventDetail); + if (!isEventValid) { logger.error({ - err: parseError, + err: messageDownloadedValidator.errors, description: 'Error parsing ttl queue entry', }); batchItemFailures.push({ itemIdentifier: messageId }); return { result: 'failed' }; } + const messageDownloadedEvent: MESHInboxMessageDownloaded = + sqsEventDetail; - const result = await createTtl.send(item.detail); + const result = await createTtl.send(messageDownloadedEvent); if (result === 'failed') { batchItemFailures.push({ itemIdentifier: messageId }); return { result: 'failed' }; } - return { result, item: item.detail }; + return { result, item: messageDownloadedEvent }; } catch (error) { logger.error({ err: error, @@ -74,6 +79,8 @@ export const createHandler = ({ failed: 0, }; + const successfulEvents: MESHInboxMessageDownloaded[] = []; + for (const result of results) { if (result.status === 'fulfilled') { const { item, result: outcome } = result.value; @@ -90,14 +97,18 @@ export const createHandler = ({ if (successfulEvents.length > 0) { try { - const failedEvents = await eventPublisher.sendEvents( + const failedEvents = await eventPublisher.sendEvents( successfulEvents.map((event) => ({ ...event, id: randomUUID(), time: new Date().toISOString(), recordedtime: new Date().toISOString(), type: 'uk.nhs.notify.digital.letters.queue.item.enqueued.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-enqueued-data.schema.json', + source: event.source.replace(/\/mesh$/, '/queue'), })), + itemEnqueuedValidator, ); if (failedEvents.length > 0) { logger.warn({ diff --git a/lambdas/ttl-create-lambda/src/app/create-ttl.ts b/lambdas/ttl-create-lambda/src/app/create-ttl.ts index 08072192..c2d6db38 100644 --- a/lambdas/ttl-create-lambda/src/app/create-ttl.ts +++ b/lambdas/ttl-create-lambda/src/app/create-ttl.ts @@ -1,5 +1,6 @@ -import { Logger, TtlItemEvent } from 'utils'; +import { Logger } from 'utils'; import { TtlRepository } from 'infra/ttl-repository'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; export type CreateTtlOutcome = 'sent' | 'failed'; @@ -9,7 +10,7 @@ export class CreateTtl { private readonly logger: Logger, ) {} - async send(item: TtlItemEvent): Promise { + async send(item: MESHInboxMessageDownloaded): Promise { try { await this.ttlDatabaseRepository.insertTtlRecord(item); } catch (error) { diff --git a/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts b/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts index 5b49d41f..efcfa327 100644 --- a/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts +++ b/lambdas/ttl-create-lambda/src/infra/ttl-repository.ts @@ -1,5 +1,6 @@ import { PutCommand, PutCommandOutput } from '@aws-sdk/lib-dynamodb'; -import { Logger, TtlItemEvent } from 'utils'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; +import { Logger } from 'utils'; interface IDynamoCaller { send: (updateCommand: PutCommand) => Promise; @@ -18,7 +19,7 @@ export class TtlRepository { this.ttlWaitTimeSeconds = ttlWaitTimeHours * 60 * 60; } - public async insertTtlRecord(item: TtlItemEvent) { + public async insertTtlRecord(item: MESHInboxMessageDownloaded) { const ttlTime = Math.round(Date.now() / 1000) + this.ttlWaitTimeSeconds; this.logger.info({ @@ -38,7 +39,10 @@ export class TtlRepository { } } - private async putTtlRecord(ttlItemEvent: TtlItemEvent, ttlTime: number) { + private async putTtlRecord( + event: MESHInboxMessageDownloaded, + ttlTime: number, + ) { // GSI PK utilising write sharding YYYY-MM-DD# const ttlGsiPk = `${ new Date(ttlTime * 1000).toISOString().split('T')[0] @@ -48,11 +52,11 @@ export class TtlRepository { new PutCommand({ TableName: this.tableName, Item: { - PK: ttlItemEvent.data.messageUri, + PK: event.data.messageUri, SK: 'TTL', ttl: ttlTime, dateOfExpiry: ttlGsiPk, - event: ttlItemEvent, + event, }, }), ); diff --git a/lambdas/ttl-handle-expiry-lambda/package.json b/lambdas/ttl-handle-expiry-lambda/package.json index ffb56f77..b20f07c1 100644 --- a/lambdas/ttl-handle-expiry-lambda/package.json +++ b/lambdas/ttl-handle-expiry-lambda/package.json @@ -3,6 +3,7 @@ "@aws-sdk/client-sqs": "^3.914.0", "@aws-sdk/util-dynamodb": "^3.928.0", "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, "devDependencies": { diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts index ced339a3..f69f1df6 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/apis/dynamodb-stream-handler.test.ts @@ -3,6 +3,7 @@ import { EventPublisher, Logger } from 'utils'; import { mock } from 'jest-mock-extended'; import { createHandler } from 'apis/dynamodb-stream-handler'; import { Dlq } from 'app/dlq'; +import itemDequeuedValidator from 'digital-letters-events/ItemDequeued.js'; const logger = mock(); const eventPublisher = mock(); @@ -29,12 +30,10 @@ const mockEvent: DynamoDBStreamEvent = { dateOfExpiry: { S: 'dateOfExpiry' }, event: { M: { - profileversion: { S: '1.0.0' }, - profilepublished: { S: '2025-10' }, id: { S: '550e8400-e29b-41d4-a716-446655440001' }, specversion: { S: '1.0' }, source: { - S: '/nhs/england/notify/production/primary/data-plane/digital-letters', + S: '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', }, subject: { S: 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', @@ -50,16 +49,12 @@ const mockEvent: DynamoDBStreamEvent = { }, datacontenttype: { S: 'application/json' }, dataschema: { - S: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', + S: 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', }, - dataschemaversion: { S: '1.0' }, severitytext: { S: 'INFO' }, data: { M: { messageUri: { S: 'https://example.com/ttl/resource' }, - 'digital-letter-id': { - S: '123e4567-e89b-12d3-a456-426614174000', - }, messageReference: { S: 'ref1' }, senderId: { S: 'sender1' }, }, @@ -108,29 +103,31 @@ describe('createHandler', () => { expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(1); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - expect.objectContaining({ - profileversion: '1.0.0', - profilepublished: '2025-10', - specversion: '1.0', - source: - '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - data: expect.objectContaining({ - 'digital-letter-id': expect.stringMatching( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, - ), - messageReference: 'ref1', - messageUri: 'https://example.com/ttl/resource', - senderId: 'sender1', + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + specversion: '1.0', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/queue', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + data: expect.objectContaining({ + messageReference: 'ref1', + messageUri: 'https://example.com/ttl/resource', + senderId: 'sender1', + }), }), - }), - ]); + ], + itemDequeuedValidator, + ); + + const publishedEvent = eventPublisher.sendEvents.mock.lastCall?.[0]; + expect(publishedEvent).toHaveLength(1); + expect(itemDequeuedValidator(publishedEvent?.[0])).toBeTruthy(); expect(result).toEqual({}); }); @@ -387,16 +384,19 @@ describe('createHandler', () => { const result = await handler(mockNotWithdrawnEvent); expect(eventPublisher.sendEvents).toHaveBeenCalledTimes(1); - expect(eventPublisher.sendEvents).toHaveBeenCalledWith([ - expect.objectContaining({ - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - data: expect.objectContaining({ - messageReference: 'ref1', - messageUri: 'https://example.com/ttl/resource', - senderId: 'sender1', + expect(eventPublisher.sendEvents).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + data: expect.objectContaining({ + messageReference: 'ref1', + messageUri: 'https://example.com/ttl/resource', + senderId: 'sender1', + }), }), - }), - ]); + ], + itemDequeuedValidator, + ); expect(result).toEqual({}); }); }); diff --git a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts index 0c624a8a..5e814ba3 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/__tests__/container.test.ts @@ -14,9 +14,7 @@ jest.mock('infra/config', () => ({ })); const mockLoadConfig = loadConfig as jest.MockedFunction; -const mockEventPublisher = EventPublisher as jest.MockedClass< - typeof EventPublisher ->; +const mockEventPublisher = jest.mocked(EventPublisher); describe('createContainer', () => { beforeEach(() => { diff --git a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts index 55d4551f..d8f42d5e 100644 --- a/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts +++ b/lambdas/ttl-handle-expiry-lambda/src/apis/dynamodb-stream-handler.ts @@ -1,17 +1,18 @@ +import { unmarshall } from '@aws-sdk/util-dynamodb'; +import { Dlq } from 'app/dlq'; import type { DynamoDBBatchItemFailure, DynamoDBRecord, DynamoDBStreamEvent, } from 'aws-lambda'; -import { unmarshall } from '@aws-sdk/util-dynamodb'; -import { - $TtlDynamodbRecord, - $TtlItemEvent, - EventPublisher, - Logger, -} from 'utils'; +import type { + ItemDequeued, + MESHInboxMessageDownloaded, +} from 'digital-letters-events'; +import itemDequeuedValidator from 'digital-letters-events/ItemDequeued.js'; +import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; import { randomUUID } from 'node:crypto'; -import { Dlq } from 'app/dlq'; +import { $TtlDynamodbRecord, EventPublisher, Logger } from 'utils'; export type CreateHandlerDependencies = { dlq: Dlq; @@ -19,6 +20,10 @@ export type CreateHandlerDependencies = { logger: Logger; }; +const eventValidator = messageDownloadedValidator as ( + d: unknown, +) => d is MESHInboxMessageDownloaded; + export const createHandler = ({ dlq, eventPublisher, @@ -63,15 +68,12 @@ export const createHandler = ({ return; } - const { - data: itemEvent, - error: eventParseError, - success: eventParseSuccess, - } = $TtlItemEvent.safeParse(item.event); - - if (!eventParseSuccess) { + let itemEvent: MESHInboxMessageDownloaded; + if (eventValidator(item.event)) { + itemEvent = item.event; + } else { logger.warn({ - err: eventParseError, + err: messageDownloadedValidator.errors, description: 'Error parsing ttl item event', }); @@ -88,15 +90,21 @@ export const createHandler = ({ senderId: itemEvent.data.senderId, }); } else { - await eventPublisher.sendEvents([ - { - ...itemEvent, - id: randomUUID(), - time: new Date().toISOString(), - recordedtime: new Date().toISOString(), - type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', - }, - ]); + await eventPublisher.sendEvents( + [ + { + ...itemEvent, + id: randomUUID(), + time: new Date().toISOString(), + recordedtime: new Date().toISOString(), + type: 'uk.nhs.notify.digital.letters.queue.item.dequeued.v1', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-queue-item-dequeued-data.schema.json', + source: itemEvent.source.replace(/\/mesh$/, '/queue'), + }, + ], + itemDequeuedValidator, + ); } } catch (error) { logger.warn({ diff --git a/package-lock.json b/package-lock.json index 529dae0d..d0171bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -297,8 +297,8 @@ "version": "0.0.1", "dependencies": { "@aws-sdk/lib-dynamodb": "^3.908.0", - "utils": "^0.0.1", - "zod": "^4.1.12" + "digital-letters-events": "^0.0.1", + "utils": "^0.0.1" }, "devDependencies": { "@tsconfig/node22": "^22.0.2", @@ -349,6 +349,7 @@ "@aws-sdk/client-sqs": "^3.914.0", "@aws-sdk/util-dynamodb": "^3.928.0", "aws-lambda": "^1.0.7", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1" }, "devDependencies": { @@ -17384,7 +17385,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.13.tgz", + "integrity": "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -17767,6 +17770,7 @@ "@aws-sdk/util-dynamodb": "^3.933.0", "@faker-js/faker": "^9.6.0", "@playwright/test": "^1.51.1", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1", "uuid": "^8.3.2" }, diff --git a/project.code-workspace b/project.code-workspace index 87670476..6aada36c 100644 --- a/project.code-workspace +++ b/project.code-workspace @@ -77,7 +77,18 @@ ".github/copilot-instructions.md": true, ".github/instructions": true }, - "terminal.integrated.scrollback": 10000 + "terminal.integrated.scrollback": 10000, + "jest.virtualFolders": [ + + { "name": "key-generation", "rootPath": "lambdas/key-generation" }, + { "name": "mesh-poll", "rootPath": "lambdas/mesh-poll" }, + { "name": "refresh-apim-access-token", "rootPath": "lambdas/refresh-apim-access-token" }, + { "name": "ttl-create-lambda", "rootPath": "lambdas/ttl-create-lambda/" }, + { "name": "ttl-handle-expiry-lambda", "rootPath": "lambdas/ttl-handle-expiry-lambda" }, + { "name": "ttl-poll-lambda", "rootPath": "lambdas/ttl-poll-lambda" }, + { "name": "sender-management", "rootPath": "utils/sender-management" }, + { "name": "utils", "rootPath": "utils/utils" }, + ], }, "extensions": { "recommendations": [ @@ -106,6 +117,7 @@ "ms-vscode.hexeditor", "ms-vscode.live-server", "ms-vsliveshare.vsliveshare", + "orta.vscode-jest", "redhat.vscode-xml", "streetsidesoftware.code-spell-checker-british-english", "takumii.markdowntable", diff --git a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts index 3c38e7a5..c98d6cb9 100644 --- a/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/create-ttl.component.spec.ts @@ -1,47 +1,66 @@ import { expect, test } from '@playwright/test'; +import { ENV } from 'constants/backend-constants'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; +import messageDownloadedValidator from 'digital-letters-events/MESHInboxMessageDownloaded.js'; +import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import { getTtl } from 'helpers/dynamodb-helpers'; import eventPublisher from 'helpers/event-bus-helpers'; import expectToPassEventually from 'helpers/expectations'; import { v4 as uuidv4 } from 'uuid'; test.describe('Digital Letters - Create TTL', () => { - test('should create TTL following downloaded message event', async () => { + test('should create TTL and publish item enqueued event following message downloaded event', async () => { const letterId = uuidv4(); const messageUri = `https://example.com/ttl/resource/${letterId}`; - await eventPublisher.sendEvents([ - { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: letterId, - specversion: '1.0', - source: - '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', - time: '2023-06-20T12:00:00Z', - recordedtime: '2023-06-20T12:00:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', - severitytext: 'INFO', - data: { - messageUri, - 'digital-letter-id': letterId, - messageReference: 'ref1', - senderId: 'sender1', + await eventPublisher.sendEvents( + [ + { + id: letterId, + specversion: '1.0', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', + subject: + 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', + type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', + time: '2023-06-20T12:00:00Z', + recordedtime: '2023-06-20T12:00:00.250Z', + severitynumber: 2, + traceparent: + '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', + datacontenttype: 'application/json', + dataschema: + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', + severitytext: 'INFO', + data: { + messageUri, + messageReference: 'ref1', + senderId: 'sender1', + }, }, - }, - ]); + ], + messageDownloadedValidator, + ); + // Verify TTL created await expectToPassEventually(async () => { const ttl = await getTtl(messageUri); expect(ttl.length).toBe(1); }); + + // Verify item enqueued event published + await expectToPassEventually(async () => { + const eventLogEntry = await getLogsFromCloudwatch( + `/aws/vendedlogs/events/event-bus/nhs-${ENV}-dl`, + [ + '$.message_type = "EVENT_RECEIPT"', + '$.details.detail_type = "uk.nhs.notify.digital.letters.queue.item.enqueued.v1"', + `$.details.event_detail = "*\\"messageUri\\":\\"${messageUri}\\"*"`, + ], + ); + + expect(eventLogEntry.length).toEqual(1); + }); }); }); diff --git a/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts b/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts index 332ef045..b70a6417 100644 --- a/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts +++ b/tests/playwright/digital-letters-component-tests/handle-ttl.component.spec.ts @@ -1,5 +1,6 @@ import { expect, test } from '@playwright/test'; import { ENV } from 'constants/backend-constants'; +import { MESHInboxMessageDownloaded } from 'digital-letters-events'; import { getLogsFromCloudwatch } from 'helpers/cloudwatch-helpers'; import { deleteTtl, putTtl } from 'helpers/dynamodb-helpers'; import expectToPassEventually from 'helpers/expectations'; @@ -13,11 +14,11 @@ test.describe('Digital Letters - Handle TTL', () => { await purgeQueue(handleTtlDlqName); }); - const baseEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', + const baseEvent: MESHInboxMessageDownloaded = { + id: 'sample-id', specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', + source: + '/nhs/england/notify/production/primary/data-plane/digitalletters/mesh', subject: 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', type: 'uk.nhs.notify.digital.letters.mesh.inbox.message.downloaded.v1', @@ -27,12 +28,12 @@ test.describe('Digital Letters - Handle TTL', () => { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', datacontenttype: 'application/json', dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', + 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10-draft/data/digital-letters-mesh-inbox-message-downloaded-data.schema.json', severitytext: 'INFO', data: { messageReference: 'ref1', senderId: 'sender1', + messageUri: 'https://example.com/ttl/resource/sample', }, }; @@ -46,9 +47,8 @@ test.describe('Digital Letters - Handle TTL', () => { data: { ...baseEvent.data, messageUri, - 'digital-letter-id': letterId, }, - }; + } satisfies MESHInboxMessageDownloaded; const ttlItem = { PK: messageUri, @@ -88,9 +88,8 @@ test.describe('Digital Letters - Handle TTL', () => { data: { ...baseEvent.data, messageUri, - 'digital-letter-id': letterId, }, - }; + } satisfies MESHInboxMessageDownloaded; const ttlItem = { PK: messageUri, diff --git a/tests/playwright/package.json b/tests/playwright/package.json index 0eecd0c0..6305e610 100644 --- a/tests/playwright/package.json +++ b/tests/playwright/package.json @@ -9,6 +9,7 @@ "@aws-sdk/util-dynamodb": "^3.933.0", "@faker-js/faker": "^9.6.0", "@playwright/test": "^1.51.1", + "digital-letters-events": "^0.0.1", "utils": "^0.0.1", "uuid": "^8.3.2" }, diff --git a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts index 9e1b5000..cb6f44e6 100644 --- a/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts +++ b/utils/utils/src/__tests__/event-publisher/event-publisher.test.ts @@ -6,7 +6,6 @@ import { } from '@aws-sdk/client-eventbridge'; import { randomInt, randomUUID } from 'node:crypto'; import { mockClient } from 'aws-sdk-client-mock'; -import { CloudEvent } from 'types'; import { Logger } from 'logger'; import { EventPublisher, EventPublisherDependencies } from 'event-publisher'; @@ -20,6 +19,8 @@ const mockLogger: Logger = { debug: jest.fn(), } as any; +type TestEvent = { id: string; source: string; type: string }; + const testConfig: EventPublisherDependencies = { eventBusArn: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', dlqUrl: 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', @@ -28,50 +29,19 @@ const testConfig: EventPublisherDependencies = { eventBridgeClient: eventBridgeMock as unknown as EventBridgeClient, }; -const validCloudEvent: CloudEvent = { - profileversion: '1.0.0', - profilepublished: '2025-10', +const event: TestEvent = { id: '550e8400-e29b-41d4-a716-446655440001', - specversion: '1.0', source: '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', type: 'uk.nhs.notify.digital.letters.sent.v1', - time: '2023-06-20T12:00:00Z', - recordedtime: '2023-06-20T12:00:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1.0', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, }; -const validCloudEvent2: CloudEvent = { - ...validCloudEvent, +const event2: TestEvent = { id: '550e8400-e29b-41d4-a716-446655440002', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174001', - messageReference: 'ref1', - senderId: 'sender1', - }, source: '/nhs/england/notify/development/primary/data-plane/digital-letters', type: 'uk.nhs.notify.digital.letters.sent.v2', }; -const invalidCloudEvent = { - type: 'data', - id: 'missing-source', -}; - -const validEvents = [validCloudEvent, validCloudEvent2]; -const invalidEvents = [invalidCloudEvent as unknown as CloudEvent]; +const events = [event, event2]; describe('Event Publishing', () => { beforeEach(() => { @@ -82,7 +52,7 @@ describe('Event Publishing', () => { test('should return empty array when no events provided', async () => { const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents([]); + const result = await publisher.sendEvents([], () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -96,7 +66,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(validEvents); + const result = await publisher.sendEvents(events, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -106,16 +76,16 @@ describe('Event Publishing', () => { expect(eventBridgeCall.args[0].input).toEqual({ Entries: [ { - Source: validCloudEvent.source, - DetailType: validCloudEvent.type, - Detail: JSON.stringify(validCloudEvent), + Source: event.source, + DetailType: event.type, + Detail: JSON.stringify(event), EventBusName: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', }, { - Source: validCloudEvent2.source, - DetailType: validCloudEvent2.type, - Detail: JSON.stringify(validCloudEvent2), + Source: event2.source, + DetailType: event2.type, + Detail: JSON.stringify(event2), EventBusName: 'arn:aws:events:us-east-1:123456789012:event-bus/test-bus', }, @@ -131,7 +101,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(invalidEvents); + const result = await publisher.sendEvents(events, () => false); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(0); @@ -142,11 +112,11 @@ describe('Event Publishing', () => { expect(sqsInput.QueueUrl).toBe( 'https://sqs.us-east-1.amazonaws.com/123456789012/test-dlq', ); - expect(sqsInput.Entries).toHaveLength(1); - expect(sqsInput.Entries[0].MessageBody).toBe( - JSON.stringify(invalidCloudEvent), - ); + expect(sqsInput.Entries).toHaveLength(2); + expect(sqsInput.Entries[0].MessageBody).toBe(JSON.stringify(events[0])); expect(sqsInput.Entries[0].Id).toBeDefined(); + expect(sqsInput.Entries[1].MessageBody).toBe(JSON.stringify(events[1])); + expect(sqsInput.Entries[1].Id).toBeDefined(); }); test('should send failed EventBridge events to DLQ', async () => { @@ -164,7 +134,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(validEvents); + const result = await publisher.sendEvents(events, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -179,9 +149,7 @@ describe('Event Publishing', () => { const sqsCall = sqsMock.calls()[0]; const sqsInput = sqsCall.args[0].input as any; expect(sqsInput.Entries).toHaveLength(1); - expect(sqsInput.Entries[0].MessageBody).toBe( - JSON.stringify(validCloudEvent), - ); + expect(sqsInput.Entries[0].MessageBody).toBe(JSON.stringify(event)); }); test('should handle EventBridge send error and send all events to DLQ', async () => { @@ -195,7 +163,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(validEvents); + const result = await publisher.sendEvents(events, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(1); @@ -218,9 +186,9 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(invalidEvents); + const result = await publisher.sendEvents(events, () => false); - expect(result).toEqual(invalidEvents); + expect(result).toEqual([event]); expect(eventBridgeMock.calls()).toHaveLength(0); expect(sqsMock.calls()).toHaveLength(1); }); @@ -229,9 +197,9 @@ describe('Event Publishing', () => { sqsMock.on(SendMessageBatchCommand).rejects(new Error('DLQ error')); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(invalidEvents); + const result = await publisher.sendEvents(events, () => false); - expect(result).toEqual(invalidEvents); + expect(result).toEqual(events); expect(eventBridgeMock.calls()).toHaveLength(0); expect(sqsMock.calls()).toHaveLength(1); }); @@ -240,7 +208,7 @@ describe('Event Publishing', () => { const largeEventArray = Array.from({ length: 25 }) .fill(null) .map(() => ({ - ...validCloudEvent, + ...event, id: randomUUID(), })); @@ -250,7 +218,7 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(largeEventArray); + const result = await publisher.sendEvents(largeEventArray, () => true); expect(result).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(3); @@ -270,12 +238,12 @@ describe('Event Publishing', () => { const largeEventArray = Array.from({ length: 25 }) .fill(null) .map(() => ({ - ...(invalidCloudEvent as unknown as CloudEvent), + ...event, id: randomUUID(), })); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(largeEventArray); + const result = await publisher.sendEvents(largeEventArray, () => false); expect(result).toEqual(largeEventArray); expect(sqsMock.calls()).toHaveLength(3); @@ -293,27 +261,27 @@ describe('Event Publishing', () => { test('should handle multiple event outcomes in one batch', async () => { const valid = Array.from({ length: 11 }, (_, i) => ({ - ...validCloudEvent, + ...event, id: `11111111-1111-1111-1111-${i.toString().padStart(12, '0')}`, })); const invalid = Array.from({ length: 12 }, (_, i) => ({ - ...(invalidCloudEvent as unknown as CloudEvent), + ...event, id: `22222222-2222-2222-2222-${i.toString().padStart(12, '0')}`, })); const invalidAndDlqError = Array.from({ length: 13 }, (_, i) => ({ - ...(invalidCloudEvent as unknown as CloudEvent), + ...event, id: `33333333-3333-3333-3333-${i.toString().padStart(12, '0')}`, })); const eventBridgeError = Array.from({ length: 14 }, (_, i) => ({ - ...validCloudEvent, + ...event, id: `44444444-4444-4444-4444-${i.toString().padStart(12, '0')}`, })); const eventBridgeAndDlqError = Array.from({ length: 15 }, (_, i) => ({ - ...validCloudEvent, + ...event, id: `55555555-5555-5555-5555-${i.toString().padStart(12, '0')}`, })); @@ -391,7 +359,14 @@ describe('Event Publishing', () => { }); const publisher = new EventPublisher(testConfig); - const result = await publisher.sendEvents(allEvents); + const result = await publisher.sendEvents( + allEvents, + (e) => + !( + e.id.includes('22222222-2222-2222-2222') || + e.id.includes('33333333-3333-3333-3333') + ), + ); expect(result).toHaveLength( invalidAndDlqError.length + eventBridgeAndDlqError.length, @@ -420,9 +395,9 @@ describe('Event Publishing', () => { // Verify invalid events are are sent to the DLQ with correct reason expect(sqsMockEntries).toEqual( expect.arrayContaining( - [...invalid, ...invalidAndDlqError].map((event) => + [...invalid, ...invalidAndDlqError].map((e) => expect.objectContaining({ - MessageBody: JSON.stringify(event), + MessageBody: JSON.stringify(e), MessageAttributes: { DlqReason: { DataType: 'String', @@ -437,9 +412,9 @@ describe('Event Publishing', () => { // Verify EventBridge failure events are sent to the DLQ with correct reason expect(sqsMockEntries).toEqual( expect.arrayContaining( - [...eventBridgeError, ...eventBridgeAndDlqError].map((event) => + [...eventBridgeError, ...eventBridgeAndDlqError].map((e) => expect.objectContaining({ - MessageBody: JSON.stringify(event), + MessageBody: JSON.stringify(e), MessageAttributes: { DlqReason: { DataType: 'String', @@ -465,11 +440,10 @@ describe('Event Publishing', () => { // Verify valid events are sent to the event bridge expect(eventBridgeMockEntries).toEqual( expect.arrayContaining( - [...valid, ...eventBridgeError, ...eventBridgeAndDlqError].map( - (event) => - expect.objectContaining({ - Detail: JSON.stringify(event), - }), + [...valid, ...eventBridgeError, ...eventBridgeAndDlqError].map((e) => + expect.objectContaining({ + Detail: JSON.stringify(e), + }), ), ), ); @@ -523,11 +497,11 @@ describe('EventPublisher Class', () => { const publisher = new EventPublisher(testConfig); // First call - const result1 = await publisher.sendEvents([validCloudEvent]); + const result1 = await publisher.sendEvents([event], () => true); expect(result1).toEqual([]); // Second call with same publisher instance - const result2 = await publisher.sendEvents([validCloudEvent2]); + const result2 = await publisher.sendEvents([event2], () => true); expect(result2).toEqual([]); expect(eventBridgeMock.calls()).toHaveLength(2); diff --git a/utils/utils/src/__tests__/types/cloud-event.test.ts b/utils/utils/src/__tests__/types/cloud-event.test.ts deleted file mode 100644 index 531d56ae..00000000 --- a/utils/utils/src/__tests__/types/cloud-event.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { $CloudEvent, validateCloudEvent } from '../../types/cloud-event'; - -describe('$CloudEvent', () => { - const valid = { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440001', - specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.example.with.multiple.words.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, - }; - - it('parses a valid CloudEvent', () => { - expect($CloudEvent.parse(valid)).toEqual(valid); - }); - - it('fails for missing required fields', () => { - expect(() => $CloudEvent.parse({})).toThrow(); - }); - - it('fails for invalid source pattern', () => { - const invalid = { ...valid, source: 'invalid-source' }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); - - it('fails for invalid subject pattern', () => { - const invalid = { ...valid, subject: 'invalid-subject' }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); - - it('fails for invalid type pattern', () => { - const invalid = { ...valid, type: 'invalid.type' }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); - - it('fails for missing digital-letter-id in data', () => { - const invalid = { ...valid, data: {} }; - expect(() => $CloudEvent.parse(invalid)).toThrow(); - }); -}); - -describe('validateCloudEvent', () => { - const valid = { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440002', - specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.example.with.multiple.words.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - }, - }; - - it('returns success for valid CloudEvent', () => { - const result = validateCloudEvent(valid); - expect(result.success).toBe(true); - expect(result.data).toEqual(valid); - }); - - it('returns failure for invalid CloudEvent', () => { - const result = validateCloudEvent({}); - expect(result.success).toBe(false); - }); -}); diff --git a/utils/utils/src/__tests__/types/ttl-item-event.test.ts b/utils/utils/src/__tests__/types/ttl-item-event.test.ts deleted file mode 100644 index e541f952..00000000 --- a/utils/utils/src/__tests__/types/ttl-item-event.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - $TtlItemEvent, - validateTtlItemEvent, -} from '../../types/ttl-item-event'; - -describe('$TtlItemEvent', () => { - const valid = { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440001', - specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.ttl.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - messageUri: 'https://example.com/ttl/resource', - }, - }; - - it('parses a valid object', () => { - expect($TtlItemEvent.parse(valid)).toEqual(valid); - }); - - it('fails for missing required fields', () => { - expect(() => $TtlItemEvent.parse({})).toThrow(); - }); - - it('fails for invalid data types', () => { - const invalid = { ...valid, id: 123, time: 'now' }; - expect(() => $TtlItemEvent.parse(invalid)).toThrow(); - }); - - it('fails for missing data.uri', () => { - const invalid = { ...valid, data: {} }; - expect(() => $TtlItemEvent.parse(invalid)).toThrow(); - }); -}); - -describe('validateTtlItemEvent', () => { - const valid = { - profileversion: '1.0.0', - profilepublished: '2025-10', - id: '550e8400-e29b-41d4-a716-446655440002', - specversion: '1.0', - source: '/nhs/england/notify/production/primary/data-plane/digital-letters', - subject: - 'customer/920fca11-596a-4eca-9c47-99f624614658/recipient/769acdd4-6a47-496f-999f-76a6fd2c3959', - type: 'uk.nhs.notify.digital.letters.ttl.v1', - time: '2024-07-10T14:30:00Z', - recordedtime: '2024-07-10T14:30:00.250Z', - severitynumber: 2, - traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01', - datacontenttype: 'application/json', - dataschema: - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - dataschemaversion: '1', - severitytext: 'INFO', - data: { - 'digital-letter-id': '123e4567-e89b-12d3-a456-426614174000', - messageReference: 'ref1', - senderId: 'sender1', - messageUri: 'https://example.com/ttl/resource', - }, - }; - - it('returns success for valid TTL item event', () => { - const result = validateTtlItemEvent(valid); - expect(result.success).toBe(true); - expect(result.data).toEqual(valid); - }); - - it('returns failure for invalid TTL item event', () => { - const result = validateTtlItemEvent({}); - expect(result.success).toBe(false); - }); -}); diff --git a/utils/utils/src/event-publisher/event-publisher.ts b/utils/utils/src/event-publisher/event-publisher.ts index 21258dd8..e7b6b45f 100644 --- a/utils/utils/src/event-publisher/event-publisher.ts +++ b/utils/utils/src/event-publisher/event-publisher.ts @@ -4,7 +4,6 @@ import { } from '@aws-sdk/client-eventbridge'; import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs'; import { randomUUID } from 'node:crypto'; -import { $CloudEvent, CloudEvent } from '../types/cloud-event'; import { Logger } from '../logger'; type DlqReason = 'INVALID_EVENT' | 'EVENTBRIDGE_FAILURE'; @@ -19,6 +18,10 @@ export interface EventPublisherDependencies { eventBridgeClient: EventBridgeClient; } +type PublishableEvent = { id: string; source: string; type: string }; + +type EventValidationFunction = { (event: T): boolean; errors?: any[] }; + export class EventPublisher { private readonly eventBridge: EventBridgeClient; @@ -51,8 +54,10 @@ export class EventPublisher { this.sqs = config.sqsClient; } - private async sendToEventBridge(events: CloudEvent[]): Promise { - const failedEvents: CloudEvent[] = []; + private async sendToEventBridge( + events: T[], + ): Promise { + const failedEvents: T[] = []; this.logger.info({ description: `Sending ${events.length} events to EventBridge`, eventCount: events.length, @@ -110,11 +115,11 @@ export class EventPublisher { return failedEvents; } - private async sendToDLQ( - events: CloudEvent[], + private async sendToDLQ( + events: T[], reason: DlqReason, - ): Promise { - const failedDlqs: CloudEvent[] = []; + ): Promise { + const failedDlqs: T[] = []; this.logger.warn({ description: 'Sending failed events to DLQ', @@ -124,7 +129,7 @@ export class EventPublisher { for (let i = 0; i < events.length; i += MAX_BATCH_SIZE) { const batch = events.slice(i, i + MAX_BATCH_SIZE); - const idToEventMap = new Map(); + const idToEventMap = new Map(); const entries = batch.map((event) => { const id = randomUUID(); @@ -183,26 +188,28 @@ export class EventPublisher { return failedDlqs; } - public async sendEvents(events: CloudEvent[]): Promise { + public async sendEvents( + events: T[], + eventValidator: EventValidationFunction, + ): Promise { if (events.length === 0) { this.logger.info({ description: 'No events to send' }); return []; } - const validEvents: CloudEvent[] = []; - const invalidEvents: CloudEvent[] = []; + const validEvents: T[] = []; + const invalidEvents: T[] = []; for (const event of events) { - // NOTE: CCM-12896 created to apply specific event validation. - const { error, success } = $CloudEvent.safeParse(event); - if (success) { + const isEventValid = eventValidator(event); + if (isEventValid) { validEvents.push(event); } else { invalidEvents.push(event); this.logger.info({ description: 'Error parsing event', - error, + error: eventValidator.errors, }); } } @@ -214,7 +221,7 @@ export class EventPublisher { totalEventCount: events.length, }); - const totalFailedEvents: CloudEvent[] = []; + const totalFailedEvents: T[] = []; if (invalidEvents.length > 0) { const failedDlqSends = await this.sendToDLQ( diff --git a/utils/utils/src/types/cloud-event.ts b/utils/utils/src/types/cloud-event.ts deleted file mode 100644 index 49f308af..00000000 --- a/utils/utils/src/types/cloud-event.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Zod schema for Digital Letters CloudEvent - -import { z } from 'zod'; - -export const $CloudEventData = z - .object({ - 'digital-letter-id': z - .string() - .regex( - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - ) - .describe('The unique identifier for the digital letter.'), - messageReference: z - .string() - .describe('The message reference from the sender.'), - senderId: z.string().describe('The identifier of the message sender.'), - }) - .catchall(z.any()); - -export type Data = z.infer; - -const $CloudEventBase = z.object({ - profileversion: z - .literal('1.0.0') - .describe('NHS Notify CloudEvents profile semantic version'), - profilepublished: z - .literal('2025-10') - .describe('NHS Notify CloudEvents profile publication date'), - specversion: z.literal('1.0').describe('CloudEvents specification version'), - id: z - .string() - .regex( - /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - ) - .describe('Unique identifier for this event instance (UUID)'), - time: z.iso - .datetime() - .describe('Timestamp when the event occurred (RFC 3339)'), - recordedtime: z.iso - .datetime() - .describe('Timestamp when the event was recorded/persisted'), - severitynumber: z - .number() - .min(0) - .max(5) - .describe( - 'Numeric severity (TRACE=0, DEBUG=1, INFO=2, WARN=3, ERROR=4, FATAL=5)', - ), - traceparent: z - .string() - .regex(/^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/) - .describe('W3C Trace Context traceparent header value'), - datacontenttype: z - .literal('application/json') - .optional() - .describe('Media type for the data field'), - dataschemaversion: z - .string() - .optional() - .describe('Version of the data schema'), - severitytext: z - .enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL']) - .optional() - .describe('Log severity level name'), - tracestate: z - .string() - .optional() - .describe('Optional W3C Trace Context tracestate header value'), - partitionkey: z - .string() - .min(1) - .max(64) - .regex(/^[a-z0-9-]+$/) - .optional() - .describe('Partition / ordering key'), - sequence: z - .string() - .regex(/^\d{20}$/) - .optional() - .describe('Zero-padded 20 digit numeric sequence'), - sampledrate: z - .number() - .int() - .min(1) - .optional() - .describe( - 'Sampling factor: number of similar occurrences this event represents', - ), - dataclassification: z - .enum(['public', 'internal', 'confidential', 'restricted']) - .optional() - .describe('Data sensitivity classification'), - dataregulation: z - .enum(['GDPR', 'HIPAA', 'PCI-DSS', 'ISO-27001', 'NIST-800-53', 'CCPA']) - .optional() - .describe('Regulatory regime tag'), - datacategory: z - .enum(['non-sensitive', 'standard', 'sensitive', 'special-category']) - .optional() - .describe('Data category classification'), -}); - -export const $CloudEvent = $CloudEventBase.extend({ - source: z - .string() - .regex( - /^\/nhs\/england\/notify\/(production|staging|development|uat)\/(primary|secondary|dev-\d+)\/data-plane\/digital-letters$/, - 'Source must match the digital letters pattern', - ) - .describe('Event source for digital letters domain'), - - subject: z - .string() - .regex( - /^customer\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\/recipient\/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/, - 'Subject must be in the format customer/{uuid}/recipient/{uuid}', - ) - .describe( - 'Path in the form customer/{id}/recipient/{id} where each {id} is a UUID', - ), - - type: z - .string() - .regex( - /^uk\.nhs\.notify\.digital\.letters\.[a-z0-9]+(?:\.[a-z0-9]+)*\.v\d+$/, - 'Type must follow the digital letters event type pattern', - ) - .describe('Concrete versioned event type string'), - - dataschema: z - .literal( - 'https://notify.nhs.uk/cloudevents/schemas/digital-letters/2025-10/digital-letter-base-data.schema.json', - ) - .describe('Canonical URI of the event data schema'), - - data: $CloudEventData.describe('Digital letters payload'), -}); - -export type CloudEvent = z.infer; - -export const validateCloudEvent = (data: unknown) => { - return $CloudEvent.safeParse(data); -}; diff --git a/utils/utils/src/types/index.ts b/utils/utils/src/types/index.ts index 738acf67..6ec81a93 100644 --- a/utils/utils/src/types/index.ts +++ b/utils/utils/src/types/index.ts @@ -1,4 +1,2 @@ -export * from './cloud-event'; export * from './ttl-dynamodb-record'; -export * from './ttl-item-event'; export * from './sender'; diff --git a/utils/utils/src/types/ttl-item-event.ts b/utils/utils/src/types/ttl-item-event.ts deleted file mode 100644 index 344eb90f..00000000 --- a/utils/utils/src/types/ttl-item-event.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { z } from 'zod'; -import { $CloudEvent, $CloudEventData } from './cloud-event'; - -export const $TtlItemData = $CloudEventData.extend({ - messageUri: z.string().min(1).describe('URI of the TTL item resource'), -}); - -export type TtlItemEventData = z.infer; - -export const $TtlItemEvent = $CloudEvent.extend({ - data: $TtlItemData, -}); - -export const $TtlItemBusEvent = z.object({ - detail: $TtlItemEvent, -}); - -export type TtlItemEvent = z.infer; - -export type TtlItemBusEvent = z.infer; - -export const validateTtlItemEvent = (data: unknown) => { - return $TtlItemEvent.safeParse(data); -};