From 3b638c2acc7a2b32ac812708af8024c822703962 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 7 Apr 2026 15:47:17 +0100 Subject: [PATCH 01/19] CCM-16444 Populate config table --- .../dispatch_internal_repo_workflow.sh | 13 ++++- .github/workflows/stage-3-build.yaml | 28 +++++++++ .../letter-variant/digitrials-aspiring.json | 38 ++++++++++++ .../letter-variant/digitrials-dmapp.json | 38 ++++++++++++ .../digitrials-globalminds.json | 38 ++++++++++++ .../letter-variant/digitrials-mymelanoma.json | 38 ++++++++++++ .../letter-variant/digitrials-ofh.json | 38 ++++++++++++ .../digitrials-prostateprogress.json | 38 ++++++++++++ .../letter-variant/digitrials-protectc.json | 38 ++++++++++++ .../letter-variant/digitrials-restore.json | 38 ++++++++++++ .../letter-variant/gpreg-admail.json | 35 +++++++++++ .../nces-abnormal-results-braille.json | 31 ++++++++++ .../letter-variant/nces-abnormal-results.json | 31 ++++++++++ .../letter-variant/nces-invites-braille.json | 31 ++++++++++ .../letter-variant/nces-invites.json | 31 ++++++++++ .../letter-variant/nces-standard-braille.json | 31 ++++++++++ .../letter-variant/nces-standard.json | 31 ++++++++++ .../letter-variant/notify-audio.json | 30 ++++++++++ .../letter-variant/notify-braille.json | 30 ++++++++++ .../notify-digital-letters-standard.json | 31 ++++++++++ .../letter-variant/notify-first.json | 30 ++++++++++ .../notify-standard-colour.json | 34 +++++++++++ .../letter-variant/notify-standard.json | 30 ++++++++++ .../digitrials-aspiring.json | 58 +++++++++++++++++++ .../digitrials-globalminds.json | 56 ++++++++++++++++++ .../digitrials-mymelanoma.json | 55 ++++++++++++++++++ .../pack-specification/digitrials-ofh.json | 55 ++++++++++++++++++ .../digitrials-prostateprogress.json | 55 ++++++++++++++++++ .../digitrials-restore.json | 58 +++++++++++++++++++ .../nces-abnormal-results-braille.json | 53 +++++++++++++++++ .../nces-abnormal-results.json | 53 +++++++++++++++++ .../nces-invites-braille.json | 51 ++++++++++++++++ .../pack-specification/nces-invites.json | 50 ++++++++++++++++ .../notify-admail-whitemail.json | 51 ++++++++++++++++ .../pack-specification/notify-admail.json | 54 +++++++++++++++++ .../pack-specification/notify-audio.json | 48 +++++++++++++++ .../notify-braille-whitemail.json | 49 ++++++++++++++++ .../pack-specification/notify-braille.json | 48 +++++++++++++++ .../pack-specification/notify-c4.json | 48 +++++++++++++++ .../pack-specification/notify-c5-colour.json | 52 +++++++++++++++++ .../notify-c5-whitemail.json | 48 +++++++++++++++ .../pack-specification/notify-c5.json | 48 +++++++++++++++ .../pack-specification/notify-first.json | 48 +++++++++++++++ .../pack-specification/notify-sameday.json | 51 ++++++++++++++++ .../datagraphic-datagraphic.json | 7 +++ .../synertec-synertec.json | 7 +++ .../datagraphic-digitrials-aspiring.json | 7 +++ .../datagraphic-digitrials-globalminds.json | 7 +++ .../datagraphic-digitrials-mymelanoma.json | 7 +++ .../datagraphic-digitrials-ofh.json | 7 +++ ...tagraphic-digitrials-prostateprogress.json | 7 +++ .../datagraphic-digitrials-restore.json | 7 +++ .../datagraphic-notify-admail.json | 7 +++ .../datagraphic-notify-audio.json | 7 +++ .../datagraphic-notify-braille-whitemail.json | 7 +++ .../datagraphic-notify-braille.json | 7 +++ .../supplier-pack/datagraphic-notify-c4.json | 7 +++ .../datagraphic-notify-c5-colour.json | 7 +++ .../datagraphic-notify-c5-whitemail.json | 7 +++ .../supplier-pack/datagraphic-notify-c5.json | 7 +++ .../datagraphic-notify-first.json | 7 +++ .../datagraphic-notify-sameday.json | 7 +++ .../supplier-pack/mba-notify-audio.json | 7 +++ .../mba-notify-braille-whitemail.json | 7 +++ .../supplier-pack/mba-notify-braille.json | 7 +++ .../supplier-pack/mba-notify-c4.json | 7 +++ .../mba-notify-c5-whitemail.json | 7 +++ .../supplier-pack/mba-notify-c5.json | 7 +++ .../supplier-pack/mba-notify-first.json | 7 +++ .../supplier-pack/mba-notify-sameday.json | 7 +++ ...ynertec-nces-abnormal-results-braille.json | 7 +++ .../synertec-nces-abnormal-results.json | 7 +++ .../synertec-nces-invites-braille.json | 7 +++ .../supplier-pack/synertec-nces-invites.json | 7 +++ .../synertec-notify-admail-whitemail.json | 7 +++ .../supplier-pack/synertec-notify-admail.json | 7 +++ .../supplier-pack/synertec-notify-audio.json | 7 +++ .../synertec-notify-braille-whitemail.json | 7 +++ .../synertec-notify-braille.json | 7 +++ .../supplier-pack/synertec-notify-c4.json | 7 +++ .../synertec-notify-c5-colour.json | 7 +++ .../synertec-notify-c5-whitemail.json | 7 +++ .../supplier-pack/synertec-notify-c5.json | 7 +++ .../supplier-pack/synertec-notify-first.json | 7 +++ .../synertec-notify-sameday.json | 7 +++ config/suppliers/supplier/datagraphic.json | 7 +++ config/suppliers/supplier/mba.json | 7 +++ config/suppliers/supplier/supplier1.json | 7 +++ config/suppliers/supplier/supplier2.json | 7 +++ config/suppliers/supplier/synertec.json | 7 +++ config/suppliers/volume-group/accessible.json | 8 +++ config/suppliers/volume-group/bau.json | 8 +++ .../suppliers/volume-group/datagraphic.json | 7 +++ config/suppliers/volume-group/sameday.json | 8 +++ config/suppliers/volume-group/synertec.json | 7 +++ .../api/ddb_table_supplier_configuration.tf | 12 ++-- 96 files changed, 2205 insertions(+), 7 deletions(-) create mode 100644 config/suppliers/letter-variant/digitrials-aspiring.json create mode 100644 config/suppliers/letter-variant/digitrials-dmapp.json create mode 100644 config/suppliers/letter-variant/digitrials-globalminds.json create mode 100644 config/suppliers/letter-variant/digitrials-mymelanoma.json create mode 100644 config/suppliers/letter-variant/digitrials-ofh.json create mode 100644 config/suppliers/letter-variant/digitrials-prostateprogress.json create mode 100644 config/suppliers/letter-variant/digitrials-protectc.json create mode 100644 config/suppliers/letter-variant/digitrials-restore.json create mode 100644 config/suppliers/letter-variant/gpreg-admail.json create mode 100644 config/suppliers/letter-variant/nces-abnormal-results-braille.json create mode 100644 config/suppliers/letter-variant/nces-abnormal-results.json create mode 100644 config/suppliers/letter-variant/nces-invites-braille.json create mode 100644 config/suppliers/letter-variant/nces-invites.json create mode 100644 config/suppliers/letter-variant/nces-standard-braille.json create mode 100644 config/suppliers/letter-variant/nces-standard.json create mode 100644 config/suppliers/letter-variant/notify-audio.json create mode 100644 config/suppliers/letter-variant/notify-braille.json create mode 100644 config/suppliers/letter-variant/notify-digital-letters-standard.json create mode 100644 config/suppliers/letter-variant/notify-first.json create mode 100644 config/suppliers/letter-variant/notify-standard-colour.json create mode 100644 config/suppliers/letter-variant/notify-standard.json create mode 100644 config/suppliers/pack-specification/digitrials-aspiring.json create mode 100644 config/suppliers/pack-specification/digitrials-globalminds.json create mode 100644 config/suppliers/pack-specification/digitrials-mymelanoma.json create mode 100644 config/suppliers/pack-specification/digitrials-ofh.json create mode 100644 config/suppliers/pack-specification/digitrials-prostateprogress.json create mode 100644 config/suppliers/pack-specification/digitrials-restore.json create mode 100644 config/suppliers/pack-specification/nces-abnormal-results-braille.json create mode 100644 config/suppliers/pack-specification/nces-abnormal-results.json create mode 100644 config/suppliers/pack-specification/nces-invites-braille.json create mode 100644 config/suppliers/pack-specification/nces-invites.json create mode 100644 config/suppliers/pack-specification/notify-admail-whitemail.json create mode 100644 config/suppliers/pack-specification/notify-admail.json create mode 100644 config/suppliers/pack-specification/notify-audio.json create mode 100644 config/suppliers/pack-specification/notify-braille-whitemail.json create mode 100644 config/suppliers/pack-specification/notify-braille.json create mode 100644 config/suppliers/pack-specification/notify-c4.json create mode 100644 config/suppliers/pack-specification/notify-c5-colour.json create mode 100644 config/suppliers/pack-specification/notify-c5-whitemail.json create mode 100644 config/suppliers/pack-specification/notify-c5.json create mode 100644 config/suppliers/pack-specification/notify-first.json create mode 100644 config/suppliers/pack-specification/notify-sameday.json create mode 100644 config/suppliers/supplier-allocation/datagraphic-datagraphic.json create mode 100644 config/suppliers/supplier-allocation/synertec-synertec.json create mode 100644 config/suppliers/supplier-pack/datagraphic-digitrials-aspiring.json create mode 100644 config/suppliers/supplier-pack/datagraphic-digitrials-globalminds.json create mode 100644 config/suppliers/supplier-pack/datagraphic-digitrials-mymelanoma.json create mode 100644 config/suppliers/supplier-pack/datagraphic-digitrials-ofh.json create mode 100644 config/suppliers/supplier-pack/datagraphic-digitrials-prostateprogress.json create mode 100644 config/suppliers/supplier-pack/datagraphic-digitrials-restore.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-admail.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-audio.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-braille-whitemail.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-braille.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-c4.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-c5-colour.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-c5-whitemail.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-c5.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-first.json create mode 100644 config/suppliers/supplier-pack/datagraphic-notify-sameday.json create mode 100644 config/suppliers/supplier-pack/mba-notify-audio.json create mode 100644 config/suppliers/supplier-pack/mba-notify-braille-whitemail.json create mode 100644 config/suppliers/supplier-pack/mba-notify-braille.json create mode 100644 config/suppliers/supplier-pack/mba-notify-c4.json create mode 100644 config/suppliers/supplier-pack/mba-notify-c5-whitemail.json create mode 100644 config/suppliers/supplier-pack/mba-notify-c5.json create mode 100644 config/suppliers/supplier-pack/mba-notify-first.json create mode 100644 config/suppliers/supplier-pack/mba-notify-sameday.json create mode 100644 config/suppliers/supplier-pack/synertec-nces-abnormal-results-braille.json create mode 100644 config/suppliers/supplier-pack/synertec-nces-abnormal-results.json create mode 100644 config/suppliers/supplier-pack/synertec-nces-invites-braille.json create mode 100644 config/suppliers/supplier-pack/synertec-nces-invites.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-admail-whitemail.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-admail.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-audio.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-braille-whitemail.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-braille.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-c4.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-c5-colour.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-c5-whitemail.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-c5.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-first.json create mode 100644 config/suppliers/supplier-pack/synertec-notify-sameday.json create mode 100644 config/suppliers/supplier/datagraphic.json create mode 100644 config/suppliers/supplier/mba.json create mode 100644 config/suppliers/supplier/supplier1.json create mode 100644 config/suppliers/supplier/supplier2.json create mode 100644 config/suppliers/supplier/synertec.json create mode 100644 config/suppliers/volume-group/accessible.json create mode 100644 config/suppliers/volume-group/bau.json create mode 100644 config/suppliers/volume-group/datagraphic.json create mode 100644 config/suppliers/volume-group/sameday.json create mode 100644 config/suppliers/volume-group/synertec.json diff --git a/.github/scripts/dispatch_internal_repo_workflow.sh b/.github/scripts/dispatch_internal_repo_workflow.sh index 714a3fed5..599292976 100755 --- a/.github/scripts/dispatch_internal_repo_workflow.sh +++ b/.github/scripts/dispatch_internal_repo_workflow.sh @@ -104,6 +104,10 @@ while [[ $# -gt 0 ]]; do version="$2" shift 2 ;; + --tableName) # Table name (optional) + tableName="$2" + shift 2 + ;; *) echo "[ERROR] Unknown argument: $1" exit 1 @@ -202,6 +206,10 @@ if [[ -z "$version" ]]; then version="" fi +if [{ -z "$tableName" }]; then + tableName="" +fi + echo "==================== Workflow Dispatch Parameters ====================" echo " infraRepoName: $infraRepoName" echo " releaseVersion: $releaseVersion" @@ -221,6 +229,7 @@ echo " apimEnvironment: $apimEnvironment" echo " boundedContext: $boundedContext" echo " targetDomain: $targetDomain" echo " version: $version" +echo " tableName: $tableName" DISPATCH_EVENT=$(jq -ncM \ --arg infraRepoName "$infraRepoName" \ @@ -240,6 +249,7 @@ DISPATCH_EVENT=$(jq -ncM \ --arg boundedContext "$boundedContext" \ --arg targetDomain "$targetDomain" \ --arg version "$version" \ + --arg tableName "$tableName" \ '{ "ref": "'"$internalRef"'", "inputs": ( @@ -255,6 +265,7 @@ DISPATCH_EVENT=$(jq -ncM \ (if $boundedContext != "" then { "boundedContext": $boundedContext } else {} end) + (if $targetDomain != "" then { "targetDomain": $targetDomain } else {} end) + (if $version != "" then { "version": $version } else {} end) + + (if $tableName != "" then { "tableName": $tableName } else {} end) + (if $targetAccountGroup != "" then { "targetAccountGroup": $targetAccountGroup } else {} end) + { "releaseVersion": $releaseVersion, @@ -269,7 +280,7 @@ echo "[INFO] Triggering workflow '$targetWorkflow' in nhs-notify-internal..." echo "[DEBUG] Dispatch event payload: $DISPATCH_EVENT" trigger_response=$(curl -s -L \ - --fail \ + --fail-with-body \ -X POST \ -H "Accept: application/vnd.github+json" \ -H "Authorization: Bearer ${PR_TRIGGER_PAT}" \ diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 3d476c2d6..516bc9473 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -184,3 +184,31 @@ jobs: runId: "${{ github.run_id }}" buildSandbox: true releaseVersion: ${{ github.head_ref || github.ref_name }} + populate-config: + name: "Populate Supplier Config" + runs-on: ubuntu-latest + needs: [pr-create-dynamic-environment] + timeout-minutes: 10 + + steps: + - name: "Checkout code" + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + - name: Set Environment Name + id: set-environment + run: echo "environment_name=${{ inputs.pr_number != '' && format('pr{0}', inputs.pr_number) || 'main' }}" >> $GITHUB_OUTPUT + - name: "Trigger populate config workflow in internal repo" + env: + APP_CLIENT_ID: ${{ secrets.APP_CLIENT_ID }} + APP_PEM_FILE: ${{ secrets.APP_PEM_FILE }} + PR_NUMBER: ${{ inputs.pr_number }} + shell: bash + run: | + .github/scripts/dispatch_internal_repo_workflow.sh \ + --infraRepoName "$(echo ${{ github.repository }} | cut -d'/' -f2)" \ + --releaseVersion ${{ github.head_ref || github.ref_name }} \ + --targetWorkflow "publish-supplier-config.yaml" \ + --targetEnvironment "${{ steps.set-environment.outputs.environment_name }}" \ + --targetComponent "config" \ + --targetAccountGroup "nhs-notify-suppliers-dev" \ + --tableName "supplier-config" \ + --internalRef "feature/CCM-12444-supplier-config-publishing-workflow" diff --git a/config/suppliers/letter-variant/digitrials-aspiring.json b/config/suppliers/letter-variant/digitrials-aspiring.json new file mode 100644 index 000000000..35a395c46 --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-aspiring.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-aspiring" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 3 + }, + "sides": { + "operator": "LESS_THAN", + "value": 6 + } + }, + "description": "Colour printing, ASPIRING envelope, Attachment", + "id": "digitrials-aspiring", + "name": "Digitrials - ASPIRING", + "packSpecificationIds": [ + "digitrials-aspiring" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-dmapp.json b/config/suppliers/letter-variant/digitrials-dmapp.json new file mode 100644 index 000000000..aa7ba5188 --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-dmapp.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-dmapp" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail, colour printing", + "id": "digitrials-dmapp", + "name": "Digitrials - DMAPP", + "packSpecificationIds": [ + "notify-admail" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-globalminds.json b/config/suppliers/letter-variant/digitrials-globalminds.json new file mode 100644 index 000000000..717c4d0a3 --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-globalminds.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-globalminds" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail?, colour printing, booklet", + "id": "digitrials-globalminds", + "name": "Digitrials - Global Minds", + "packSpecificationIds": [ + "digitrials-globalminds" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-mymelanoma.json b/config/suppliers/letter-variant/digitrials-mymelanoma.json new file mode 100644 index 000000000..75414128a --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-mymelanoma.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-mymelanoma" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail, colour printing, MyMelanoma envelope", + "id": "digitrials-mymelanoma", + "name": "Digitrials - MyMelanoma", + "packSpecificationIds": [ + "digitrials-mymelanoma" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-ofh.json b/config/suppliers/letter-variant/digitrials-ofh.json new file mode 100644 index 000000000..7fd4b6f58 --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-ofh.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-ofh" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail, colour printing, OFH envelope", + "id": "digitrials-ofh", + "name": "Digitrials - Our Future Health", + "packSpecificationIds": [ + "digitrials-ofh" + ], + "status": "PROD", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-prostateprogress.json b/config/suppliers/letter-variant/digitrials-prostateprogress.json new file mode 100644 index 000000000..712599250 --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-prostateprogress.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-prostateprogress" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Colour printing, PP envelope, Attachment", + "id": "digitrials-prostateprogress", + "name": "Digitrials - Prostate Progress", + "packSpecificationIds": [ + "digitrials-prostateprogress" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-protectc.json b/config/suppliers/letter-variant/digitrials-protectc.json new file mode 100644 index 000000000..156ff14b3 --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-protectc.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-protectc" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Economy, colour printing", + "id": "digitrials-protectc", + "name": "Digitrials - PROTECT-C", + "packSpecificationIds": [ + "notify-c5-colour" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/digitrials-restore.json b/config/suppliers/letter-variant/digitrials-restore.json new file mode 100644 index 000000000..de6be6f3a --- /dev/null +++ b/config/suppliers/letter-variant/digitrials-restore.json @@ -0,0 +1,38 @@ +{ + "campaignIds": [ + "digitrials-restore" + ], + "clientId": "digitrials", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Admail?, colour printing, booklet", + "id": "digitrials-restore", + "name": "Digitrials - Restore", + "packSpecificationIds": [ + "digitrials-restore" + ], + "status": "INT", + "supplierId": "datagraphic", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/gpreg-admail.json b/config/suppliers/letter-variant/gpreg-admail.json new file mode 100644 index 000000000..8679e96e9 --- /dev/null +++ b/config/suppliers/letter-variant/gpreg-admail.json @@ -0,0 +1,35 @@ +{ + "clientId": "gp-reg", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Letter with admail postage tariff", + "id": "gpreg-admail", + "name": "Admail letter", + "packSpecificationIds": [ + "notify-admail" + ], + "status": "INT", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "synertec" +} diff --git a/config/suppliers/letter-variant/nces-abnormal-results-braille.json b/config/suppliers/letter-variant/nces-abnormal-results-braille.json new file mode 100644 index 000000000..0a42c9415 --- /dev/null +++ b/config/suppliers/letter-variant/nces-abnormal-results-braille.json @@ -0,0 +1,31 @@ +{ + "clientId": "nces", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Same Day, Braille, Whitemail, Booklet", + "id": "nces-abnormal-results-braille", + "name": "Cervical Screening Braille Abnormal Results", + "packSpecificationIds": [ + "nces-abnormal-results-braille" + ], + "status": "INT", + "supplierId": "synertec", + "type": "BRAILLE", + "volumeGroupId": "accessible" +} diff --git a/config/suppliers/letter-variant/nces-abnormal-results.json b/config/suppliers/letter-variant/nces-abnormal-results.json new file mode 100644 index 000000000..0fd4b659f --- /dev/null +++ b/config/suppliers/letter-variant/nces-abnormal-results.json @@ -0,0 +1,31 @@ +{ + "clientId": "nces", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Same Day, Whitemail, Booklet", + "id": "nces-abnormal-results", + "name": "Cervical Screening Abnormal Results", + "packSpecificationIds": [ + "nces-abnormal-results" + ], + "status": "INT", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "sameday" +} diff --git a/config/suppliers/letter-variant/nces-invites-braille.json b/config/suppliers/letter-variant/nces-invites-braille.json new file mode 100644 index 000000000..3d98c879a --- /dev/null +++ b/config/suppliers/letter-variant/nces-invites-braille.json @@ -0,0 +1,31 @@ +{ + "clientId": "nces", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Braille, Whitemail, Booklet", + "id": "nces-invites-braille", + "name": "Cervical Screening Braille Invites", + "packSpecificationIds": [ + "nces-invites-braille" + ], + "status": "INT", + "supplierId": "synertec", + "type": "BRAILLE", + "volumeGroupId": "accessible" +} diff --git a/config/suppliers/letter-variant/nces-invites.json b/config/suppliers/letter-variant/nces-invites.json new file mode 100644 index 000000000..95dcbd804 --- /dev/null +++ b/config/suppliers/letter-variant/nces-invites.json @@ -0,0 +1,31 @@ +{ + "clientId": "nces", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "description": "Business Economy, Whitemail, Booklet", + "id": "nces-invites", + "name": "Cervical Screening Invites", + "packSpecificationIds": [ + "nces-invites" + ], + "status": "INT", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "bau" +} diff --git a/config/suppliers/letter-variant/nces-standard-braille.json b/config/suppliers/letter-variant/nces-standard-braille.json new file mode 100644 index 000000000..191eebbe1 --- /dev/null +++ b/config/suppliers/letter-variant/nces-standard-braille.json @@ -0,0 +1,31 @@ +{ + "clientId": "nces", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Braille, Whitemail", + "id": "nces-standard-braille", + "name": "Cervical Screening Standard Braille Letters", + "packSpecificationIds": [ + "notify-braille-whitemail" + ], + "status": "INT", + "supplierId": "synertec", + "type": "BRAILLE", + "volumeGroupId": "accessible" +} diff --git a/config/suppliers/letter-variant/nces-standard.json b/config/suppliers/letter-variant/nces-standard.json new file mode 100644 index 000000000..d512df617 --- /dev/null +++ b/config/suppliers/letter-variant/nces-standard.json @@ -0,0 +1,31 @@ +{ + "clientId": "nces", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Business Economy, Whitemail", + "id": "nces-standard", + "name": "Cervical Screening Standard Letters", + "packSpecificationIds": [ + "notify-c5-whitemail" + ], + "status": "PROD", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "synertec" +} diff --git a/config/suppliers/letter-variant/notify-audio.json b/config/suppliers/letter-variant/notify-audio.json new file mode 100644 index 000000000..bb80b3e4b --- /dev/null +++ b/config/suppliers/letter-variant/notify-audio.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Audio CD with standard letter", + "id": "notify-audio", + "name": "Audio CD Letter", + "packSpecificationIds": [ + "notify-audio" + ], + "status": "DRAFT", + "supplierId": "synertec", + "type": "AUDIO", + "volumeGroupId": "accessible" +} diff --git a/config/suppliers/letter-variant/notify-braille.json b/config/suppliers/letter-variant/notify-braille.json new file mode 100644 index 000000000..90fd88153 --- /dev/null +++ b/config/suppliers/letter-variant/notify-braille.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Braille letter with standard letter", + "id": "notify-braille", + "name": "Braille Letter", + "packSpecificationIds": [ + "notify-braille" + ], + "status": "INT", + "supplierId": "synertec", + "type": "BRAILLE", + "volumeGroupId": "accessible" +} diff --git a/config/suppliers/letter-variant/notify-digital-letters-standard.json b/config/suppliers/letter-variant/notify-digital-letters-standard.json new file mode 100644 index 000000000..1cb96e32c --- /dev/null +++ b/config/suppliers/letter-variant/notify-digital-letters-standard.json @@ -0,0 +1,31 @@ +{ + "clientId": "digital-letters", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Black printing, economy postage tariff", + "id": "notify-digital-letters-standard", + "name": "Standard Letter Variant for Digital Letters fallback", + "packSpecificationIds": [ + "notify-c5" + ], + "status": "INT", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "synertec" +} diff --git a/config/suppliers/letter-variant/notify-first.json b/config/suppliers/letter-variant/notify-first.json new file mode 100644 index 000000000..a3741e885 --- /dev/null +++ b/config/suppliers/letter-variant/notify-first.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Black printing, first class postage tariff", + "id": "notify-first", + "name": "First class letter", + "packSpecificationIds": [ + "notify-first" + ], + "status": "DRAFT", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "bau" +} diff --git a/config/suppliers/letter-variant/notify-standard-colour.json b/config/suppliers/letter-variant/notify-standard-colour.json new file mode 100644 index 000000000..d21617cb5 --- /dev/null +++ b/config/suppliers/letter-variant/notify-standard-colour.json @@ -0,0 +1,34 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Colour printing, economy postage tariff", + "id": "notify-standard-colour", + "name": "Standard Letter (colour)", + "packSpecificationIds": [ + "notify-c5-colour" + ], + "status": "INT", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "datagraphic" +} diff --git a/config/suppliers/letter-variant/notify-standard.json b/config/suppliers/letter-variant/notify-standard.json new file mode 100644 index 000000000..5b0a4d9b8 --- /dev/null +++ b/config/suppliers/letter-variant/notify-standard.json @@ -0,0 +1,30 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Black printing, economy postage tariff", + "id": "notify-standard", + "name": "Standard Letter", + "packSpecificationIds": [ + "notify-c5" + ], + "status": "PROD", + "supplierId": "synertec", + "type": "STANDARD", + "volumeGroupId": "synertec" +} diff --git a/config/suppliers/pack-specification/digitrials-aspiring.json b/config/suppliers/pack-specification/digitrials-aspiring.json new file mode 100644 index 000000000..d9a478388 --- /dev/null +++ b/config/suppliers/pack-specification/digitrials-aspiring.json @@ -0,0 +1,58 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "digitrials-aspiring", + "features": [ + "ADMAIL" + ], + "insertIds": [ + "digitrials-aspiring-fast-facts-attachment" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-100", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope and attachment for ASPIRING", + "id": "digitrials-aspiring", + "name": "Digitrials - ASPIRING", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-02-16T17:00:00.000Z", + "version": 2 +} diff --git a/config/suppliers/pack-specification/digitrials-globalminds.json b/config/suppliers/pack-specification/digitrials-globalminds.json new file mode 100644 index 000000000..e7aa11130 --- /dev/null +++ b/config/suppliers/pack-specification/digitrials-globalminds.json @@ -0,0 +1,56 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "insertIds": [ + "digitrials-globalminds-booklet" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope and booklet for Global Minds", + "id": "digitrials-globalminds", + "name": "Digitrials - Global Minds", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/digitrials-mymelanoma.json b/config/suppliers/pack-specification/digitrials-mymelanoma.json new file mode 100644 index 000000000..1061f995a --- /dev/null +++ b/config/suppliers/pack-specification/digitrials-mymelanoma.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "digitrials-mymelanoma", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope for My Melanoma", + "id": "digitrials-mymelanoma", + "name": "Digitrials - MyMelanoma", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/digitrials-ofh.json b/config/suppliers/pack-specification/digitrials-ofh.json new file mode 100644 index 000000000..fd143e8ba --- /dev/null +++ b/config/suppliers/pack-specification/digitrials-ofh.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "digitrials-ofh", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Envelope for Our Future Health", + "id": "digitrials-ofh", + "name": "Digitrials - Our Future Health", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/digitrials-prostateprogress.json b/config/suppliers/pack-specification/digitrials-prostateprogress.json new file mode 100644 index 000000000..bb6cd9bbc --- /dev/null +++ b/config/suppliers/pack-specification/digitrials-prostateprogress.json @@ -0,0 +1,55 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "digitrials-prostateprogress", + "insertIds": [ + "digitrials-prostateprogress-leaflet" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-02-04T00:00:00.000Z", + "description": "Envelope and insert for Prostate Progress", + "id": "digitrials-prostateprogress", + "name": "Digitrials - Prostate Progress", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-02-04T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/digitrials-restore.json b/config/suppliers/pack-specification/digitrials-restore.json new file mode 100644 index 000000000..d3a5b3c4b --- /dev/null +++ b/config/suppliers/pack-specification/digitrials-restore.json @@ -0,0 +1,58 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "insertIds": [ + "digitrials-restore-leaflet" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 4 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Insert for Restore", + "id": "digitrials-restore", + "name": "Digitrials - Restore", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/nces-abnormal-results-braille.json b/config/suppliers/pack-specification/nces-abnormal-results-braille.json new file mode 100644 index 000000000..97d3eaab7 --- /dev/null +++ b/config/suppliers/pack-specification/nces-abnormal-results-braille.json @@ -0,0 +1,53 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille-whitemail", + "features": [ + "BRAILLE", + "SAME_DAY" + ], + "insertIds": [ + "CSP15" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "sameday-insert-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "nces-abnormal-results-braille", + "name": "NCES Abnormal Results Braille", + "postage": { + "deliveryDays": 1, + "id": "articles-blind", + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "INT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/nces-abnormal-results.json b/config/suppliers/pack-specification/nces-abnormal-results.json new file mode 100644 index 000000000..600fd4aca --- /dev/null +++ b/config/suppliers/pack-specification/nces-abnormal-results.json @@ -0,0 +1,53 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5-whitemail", + "features": [ + "SAME_DAY" + ], + "insertIds": [ + "CSP15" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "sameday-insert-c5", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 1 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "nces-abnormal-results", + "name": "NCES Abnormal Results", + "postage": { + "deliveryDays": 1, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "INT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/nces-invites-braille.json b/config/suppliers/pack-specification/nces-invites-braille.json new file mode 100644 index 000000000..f24712659 --- /dev/null +++ b/config/suppliers/pack-specification/nces-invites-braille.json @@ -0,0 +1,51 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille-whitemail", + "features": [ + "BRAILLE" + ], + "insertIds": [ + "CSP14" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "insert-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "nces-invites-braille", + "name": "NCES Invites Braille", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "INT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/nces-invites.json b/config/suppliers/pack-specification/nces-invites.json new file mode 100644 index 000000000..e057cc35d --- /dev/null +++ b/config/suppliers/pack-specification/nces-invites.json @@ -0,0 +1,50 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5-whitemail", + "insertIds": [ + "CSP14" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 4 + }, + "sides": { + "operator": "LESS_THAN", + "value": 8 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "nces-invites", + "name": "NCES Invites", + "postage": { + "deliveryDays": 3, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "INT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-admail-whitemail.json b/config/suppliers/pack-specification/notify-admail-whitemail.json new file mode 100644 index 000000000..0132b71f9 --- /dev/null +++ b/config/suppliers/pack-specification/notify-admail-whitemail.json @@ -0,0 +1,51 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "insert-admail-economy", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Admail economy tariff with whitemail envelope", + "id": "notify-admail-whitemail", + "name": "Admail (whitemail)", + "postage": { + "deliveryDays": 4, + "id": "admail-economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-admail.json b/config/suppliers/pack-specification/notify-admail.json new file mode 100644 index 000000000..7344a939b --- /dev/null +++ b/config/suppliers/pack-specification/notify-admail.json @@ -0,0 +1,54 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "features": [ + "ADMAIL" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "admail", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Admail economy tariff, B&W", + "id": "notify-admail", + "name": "Admail", + "postage": { + "id": "admail", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 2 +} diff --git a/config/suppliers/pack-specification/notify-audio.json b/config/suppliers/pack-specification/notify-audio.json new file mode 100644 index 000000000..e5fb5002e --- /dev/null +++ b/config/suppliers/pack-specification/notify-audio.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "audio", + "features": [ + "AUDIO" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-audio", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "notify-audio", + "name": "Audio CD", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "DRAFT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-braille-whitemail.json b/config/suppliers/pack-specification/notify-braille-whitemail.json new file mode 100644 index 000000000..2ef1f05f6 --- /dev/null +++ b/config/suppliers/pack-specification/notify-braille-whitemail.json @@ -0,0 +1,49 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille-whitemail", + "features": [ + "BRAILLE" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "notify-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Braille pack with whitemail return address", + "id": "notify-braille-whitemail", + "name": "Braille letter (whitemail)", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-braille.json b/config/suppliers/pack-specification/notify-braille.json new file mode 100644 index 000000000..4417ce98d --- /dev/null +++ b/config/suppliers/pack-specification/notify-braille.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "braille", + "features": [ + "BRAILLE" + ], + "paper": { + "colour": "WHITE", + "id": "paper-braille", + "name": "Braille Paper", + "recycled": true, + "size": "A4", + "weightGSM": 120 + }, + "printColour": "BLACK" + }, + "billingId": "notify-braille", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "id": "notify-braille", + "name": "Braille Letter", + "postage": { + "deliveryDays": 3, + "id": "articles-blind", + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-c4.json b/config/suppliers/pack-specification/notify-c4.json new file mode 100644 index 000000000..dc42c0f1b --- /dev/null +++ b/config/suppliers/pack-specification/notify-c4.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-c4", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 20 + }, + "sides": { + "operator": "LESS_THAN", + "value": 40 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "C4 pack (large letter)", + "id": "notify-c4", + "name": "Notify standard (C4)", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 25, + "maxWeightGrams": 500, + "size": "LARGE" + }, + "status": "DRAFT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-c5-colour.json b/config/suppliers/pack-specification/notify-c5-colour.json new file mode 100644 index 000000000..fdcee099d --- /dev/null +++ b/config/suppliers/pack-specification/notify-c5-colour.json @@ -0,0 +1,52 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "COLOUR" + }, + "billingId": "notify-c5-colour", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "colourCoveragePercentage": { + "operator": "LESS_THAN", + "value": 10 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "C5 pack with colour printing", + "id": "notify-c5-colour", + "name": "Notify standard (colour)", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "INT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-c5-whitemail.json b/config/suppliers/pack-specification/notify-c5-whitemail.json new file mode 100644 index 000000000..0341599ea --- /dev/null +++ b/config/suppliers/pack-specification/notify-c5-whitemail.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c5-whitemail", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-c5", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "C5 pack with whitemail return address", + "id": "notify-c5-whitemail", + "name": "Notify standard (whitemail)", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-c5.json b/config/suppliers/pack-specification/notify-c5.json new file mode 100644 index 000000000..b656c17fa --- /dev/null +++ b/config/suppliers/pack-specification/notify-c5.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "economy-c4", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-c5", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "C5 pack", + "id": "notify-c5", + "name": "Notify standard", + "postage": { + "deliveryDays": 3, + "id": "economy", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "PROD", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-first.json b/config/suppliers/pack-specification/notify-first.json new file mode 100644 index 000000000..55543a47e --- /dev/null +++ b/config/suppliers/pack-specification/notify-first.json @@ -0,0 +1,48 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5", + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "notify-first", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "First class postage tariff", + "id": "notify-first", + "name": "First class", + "postage": { + "deliveryDays": 2, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "DRAFT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/pack-specification/notify-sameday.json b/config/suppliers/pack-specification/notify-sameday.json new file mode 100644 index 000000000..66b350848 --- /dev/null +++ b/config/suppliers/pack-specification/notify-sameday.json @@ -0,0 +1,51 @@ +{ + "assembly": { + "duplex": true, + "envelopeId": "first-c5", + "features": [ + "SAME_DAY" + ], + "paper": { + "colour": "WHITE", + "id": "paper-std-white-80", + "name": "Standard White 80gsm", + "recycled": true, + "size": "A4", + "weightGSM": 80 + }, + "printColour": "BLACK" + }, + "billingId": "sameday", + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 2 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "createdAt": "2026-01-12T00:00:00.000Z", + "description": "Same day production and dispatch", + "id": "notify-sameday", + "name": "Same day dispatch", + "postage": { + "deliveryDays": 1, + "id": "first-class", + "maxThicknessMm": 5, + "maxWeightGrams": 100, + "size": "STANDARD" + }, + "status": "DRAFT", + "updatedAt": "2026-01-12T00:00:00.000Z", + "version": 1 +} diff --git a/config/suppliers/supplier-allocation/datagraphic-datagraphic.json b/config/suppliers/supplier-allocation/datagraphic-datagraphic.json new file mode 100644 index 000000000..984f34e83 --- /dev/null +++ b/config/suppliers/supplier-allocation/datagraphic-datagraphic.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 100, + "id": "datagraphic-datagraphic", + "status": "PROD", + "supplier": "datagraphic", + "volumeGroup": "datagraphic" +} diff --git a/config/suppliers/supplier-allocation/synertec-synertec.json b/config/suppliers/supplier-allocation/synertec-synertec.json new file mode 100644 index 000000000..ddedeb824 --- /dev/null +++ b/config/suppliers/supplier-allocation/synertec-synertec.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 100, + "id": "synertec-synertec", + "status": "PROD", + "supplier": "synertec", + "volumeGroup": "synertec" +} diff --git a/config/suppliers/supplier-pack/datagraphic-digitrials-aspiring.json b/config/suppliers/supplier-pack/datagraphic-digitrials-aspiring.json new file mode 100644 index 000000000..f301ac24e --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-digitrials-aspiring.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-digitrials-aspiring", + "packSpecificationId": "digitrials-aspiring", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-digitrials-globalminds.json b/config/suppliers/supplier-pack/datagraphic-digitrials-globalminds.json new file mode 100644 index 000000000..3970a0775 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-digitrials-globalminds.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-digitrials-globalminds", + "packSpecificationId": "digitrials-globalminds", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-digitrials-mymelanoma.json b/config/suppliers/supplier-pack/datagraphic-digitrials-mymelanoma.json new file mode 100644 index 000000000..f39b6bdf3 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-digitrials-mymelanoma.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-digitrials-mymelanoma", + "packSpecificationId": "digitrials-mymelanoma", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-digitrials-ofh.json b/config/suppliers/supplier-pack/datagraphic-digitrials-ofh.json new file mode 100644 index 000000000..0dca38fa7 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-digitrials-ofh.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-digitrials-ofh", + "packSpecificationId": "digitrials-ofh", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-digitrials-prostateprogress.json b/config/suppliers/supplier-pack/datagraphic-digitrials-prostateprogress.json new file mode 100644 index 000000000..0926aa2c6 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-digitrials-prostateprogress.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-digitrials-prostateprogress", + "packSpecificationId": "digitrials-prostateprogress", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-digitrials-restore.json b/config/suppliers/supplier-pack/datagraphic-digitrials-restore.json new file mode 100644 index 000000000..72c7e1212 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-digitrials-restore.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-digitrials-restore", + "packSpecificationId": "digitrials-restore", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-admail.json b/config/suppliers/supplier-pack/datagraphic-notify-admail.json new file mode 100644 index 000000000..ebf12c1f8 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-admail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-notify-admail", + "packSpecificationId": "notify-admail", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-audio.json b/config/suppliers/supplier-pack/datagraphic-notify-audio.json new file mode 100644 index 000000000..20cbd2f80 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-audio.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "datagraphic-notify-audio", + "packSpecificationId": "notify-audio", + "status": "DRAFT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-braille-whitemail.json b/config/suppliers/supplier-pack/datagraphic-notify-braille-whitemail.json new file mode 100644 index 000000000..2c89088bd --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-braille-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "datagraphic-notify-braille-whitemail", + "packSpecificationId": "notify-braille-whitemail", + "status": "DRAFT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-braille.json b/config/suppliers/supplier-pack/datagraphic-notify-braille.json new file mode 100644 index 000000000..d46fcd8ef --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "datagraphic-notify-braille", + "packSpecificationId": "notify-braille", + "status": "DRAFT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-c4.json b/config/suppliers/supplier-pack/datagraphic-notify-c4.json new file mode 100644 index 000000000..e54cd61e9 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-c4.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "datagraphic-notify-c4", + "packSpecificationId": "notify-c4", + "status": "DRAFT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-c5-colour.json b/config/suppliers/supplier-pack/datagraphic-notify-c5-colour.json new file mode 100644 index 000000000..aca5790aa --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-c5-colour.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-notify-c5-colour", + "packSpecificationId": "notify-c5-colour", + "status": "INT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-c5-whitemail.json b/config/suppliers/supplier-pack/datagraphic-notify-c5-whitemail.json new file mode 100644 index 000000000..98daba720 --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-c5-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-notify-c5-whitemail", + "packSpecificationId": "notify-c5-whitemail", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-c5.json b/config/suppliers/supplier-pack/datagraphic-notify-c5.json new file mode 100644 index 000000000..cea709c6e --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-c5.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "datagraphic-notify-c5", + "packSpecificationId": "notify-c5", + "status": "PROD", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-first.json b/config/suppliers/supplier-pack/datagraphic-notify-first.json new file mode 100644 index 000000000..a5caef81f --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-first.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "datagraphic-notify-first", + "packSpecificationId": "notify-first", + "status": "DRAFT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/datagraphic-notify-sameday.json b/config/suppliers/supplier-pack/datagraphic-notify-sameday.json new file mode 100644 index 000000000..309901e0b --- /dev/null +++ b/config/suppliers/supplier-pack/datagraphic-notify-sameday.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "datagraphic-notify-sameday", + "packSpecificationId": "notify-sameday", + "status": "DRAFT", + "supplierId": "datagraphic" +} diff --git a/config/suppliers/supplier-pack/mba-notify-audio.json b/config/suppliers/supplier-pack/mba-notify-audio.json new file mode 100644 index 000000000..6a4f53398 --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-audio.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-audio", + "packSpecificationId": "notify-audio", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-braille-whitemail.json b/config/suppliers/supplier-pack/mba-notify-braille-whitemail.json new file mode 100644 index 000000000..00f97f80a --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-braille-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-braille-whitemail", + "packSpecificationId": "notify-braille-whitemail", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-braille.json b/config/suppliers/supplier-pack/mba-notify-braille.json new file mode 100644 index 000000000..c1bedcabd --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-braille", + "packSpecificationId": "notify-braille", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-c4.json b/config/suppliers/supplier-pack/mba-notify-c4.json new file mode 100644 index 000000000..2d5b12c0d --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-c4.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-c4", + "packSpecificationId": "notify-c4", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-c5-whitemail.json b/config/suppliers/supplier-pack/mba-notify-c5-whitemail.json new file mode 100644 index 000000000..d5daf8217 --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-c5-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-c5-whitemail", + "packSpecificationId": "notify-c5-whitemail", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-c5.json b/config/suppliers/supplier-pack/mba-notify-c5.json new file mode 100644 index 000000000..5c9f7e67c --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-c5.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-c5", + "packSpecificationId": "notify-c5", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-first.json b/config/suppliers/supplier-pack/mba-notify-first.json new file mode 100644 index 000000000..8b2092d95 --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-first.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-first", + "packSpecificationId": "notify-first", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/mba-notify-sameday.json b/config/suppliers/supplier-pack/mba-notify-sameday.json new file mode 100644 index 000000000..208210918 --- /dev/null +++ b/config/suppliers/supplier-pack/mba-notify-sameday.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "mba-notify-sameday", + "packSpecificationId": "notify-sameday", + "status": "DRAFT", + "supplierId": "mba" +} diff --git a/config/suppliers/supplier-pack/synertec-nces-abnormal-results-braille.json b/config/suppliers/supplier-pack/synertec-nces-abnormal-results-braille.json new file mode 100644 index 000000000..b27f23748 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-nces-abnormal-results-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "SUBMITTED", + "id": "synertec-nces-abnormal-results-braille", + "packSpecificationId": "nces-abnormal-results-braille", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-nces-abnormal-results.json b/config/suppliers/supplier-pack/synertec-nces-abnormal-results.json new file mode 100644 index 000000000..52c28638d --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-nces-abnormal-results.json @@ -0,0 +1,7 @@ +{ + "approval": "SUBMITTED", + "id": "synertec-nces-abnormal-results", + "packSpecificationId": "nces-abnormal-results", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-nces-invites-braille.json b/config/suppliers/supplier-pack/synertec-nces-invites-braille.json new file mode 100644 index 000000000..b22f26ef4 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-nces-invites-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "SUBMITTED", + "id": "synertec-nces-invites-braille", + "packSpecificationId": "nces-invites-braille", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-nces-invites.json b/config/suppliers/supplier-pack/synertec-nces-invites.json new file mode 100644 index 000000000..e84eea5ae --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-nces-invites.json @@ -0,0 +1,7 @@ +{ + "approval": "SUBMITTED", + "id": "synertec-nces-invites", + "packSpecificationId": "nces-invites", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-admail-whitemail.json b/config/suppliers/supplier-pack/synertec-notify-admail-whitemail.json new file mode 100644 index 000000000..290abcd88 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-admail-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "synertec-notify-admail-whitemail", + "packSpecificationId": "notify-admail-whitemail", + "status": "PROD", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-admail.json b/config/suppliers/supplier-pack/synertec-notify-admail.json new file mode 100644 index 000000000..96bd162d3 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-admail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "synertec-notify-admail", + "packSpecificationId": "notify-admail", + "status": "PROD", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-audio.json b/config/suppliers/supplier-pack/synertec-notify-audio.json new file mode 100644 index 000000000..6b46cc572 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-audio.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "synertec-notify-audio", + "packSpecificationId": "notify-audio", + "status": "DRAFT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-braille-whitemail.json b/config/suppliers/supplier-pack/synertec-notify-braille-whitemail.json new file mode 100644 index 000000000..7743da115 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-braille-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "synertec-notify-braille-whitemail", + "packSpecificationId": "notify-braille-whitemail", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-braille.json b/config/suppliers/supplier-pack/synertec-notify-braille.json new file mode 100644 index 000000000..8ec9cf25f --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-braille.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "synertec-notify-braille", + "packSpecificationId": "notify-braille", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-c4.json b/config/suppliers/supplier-pack/synertec-notify-c4.json new file mode 100644 index 000000000..060591839 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-c4.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "synertec-notify-c4", + "packSpecificationId": "notify-c4", + "status": "DRAFT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-c5-colour.json b/config/suppliers/supplier-pack/synertec-notify-c5-colour.json new file mode 100644 index 000000000..16ca8e717 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-c5-colour.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "synertec-notify-c5-colour", + "packSpecificationId": "notify-c5-colour", + "status": "INT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-c5-whitemail.json b/config/suppliers/supplier-pack/synertec-notify-c5-whitemail.json new file mode 100644 index 000000000..301ec4806 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-c5-whitemail.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "synertec-notify-c5-whitemail", + "packSpecificationId": "notify-c5-whitemail", + "status": "PROD", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-c5.json b/config/suppliers/supplier-pack/synertec-notify-c5.json new file mode 100644 index 000000000..80752c2f3 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-c5.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "synertec-notify-c5", + "packSpecificationId": "notify-c5", + "status": "PROD", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-first.json b/config/suppliers/supplier-pack/synertec-notify-first.json new file mode 100644 index 000000000..9617adbd1 --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-first.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "synertec-notify-first", + "packSpecificationId": "notify-first", + "status": "DRAFT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier-pack/synertec-notify-sameday.json b/config/suppliers/supplier-pack/synertec-notify-sameday.json new file mode 100644 index 000000000..50f9ff9fd --- /dev/null +++ b/config/suppliers/supplier-pack/synertec-notify-sameday.json @@ -0,0 +1,7 @@ +{ + "approval": "DRAFT", + "id": "synertec-notify-sameday", + "packSpecificationId": "notify-sameday", + "status": "DRAFT", + "supplierId": "synertec" +} diff --git a/config/suppliers/supplier/datagraphic.json b/config/suppliers/supplier/datagraphic.json new file mode 100644 index 000000000..40d0b2ea3 --- /dev/null +++ b/config/suppliers/supplier/datagraphic.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "datagraphic", + "name": "PSL/Datagraphic", + "status": "PROD" +} diff --git a/config/suppliers/supplier/mba.json b/config/suppliers/supplier/mba.json new file mode 100644 index 000000000..2016fb408 --- /dev/null +++ b/config/suppliers/supplier/mba.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "mba", + "name": "MBA Group", + "status": "INT" +} diff --git a/config/suppliers/supplier/supplier1.json b/config/suppliers/supplier/supplier1.json new file mode 100644 index 000000000..5ef81166a --- /dev/null +++ b/config/suppliers/supplier/supplier1.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "supplier1", + "name": "Supplier1", + "status": "PROD" +} diff --git a/config/suppliers/supplier/supplier2.json b/config/suppliers/supplier/supplier2.json new file mode 100644 index 000000000..883da65cc --- /dev/null +++ b/config/suppliers/supplier/supplier2.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "supplier2", + "name": "Supplier2", + "status": "PROD" +} diff --git a/config/suppliers/supplier/synertec.json b/config/suppliers/supplier/synertec.json new file mode 100644 index 000000000..faa8ef45a --- /dev/null +++ b/config/suppliers/supplier/synertec.json @@ -0,0 +1,7 @@ +{ + "channelType": "LETTER", + "dailyCapacity": 500000, + "id": "synertec", + "name": "Synertec", + "status": "PROD" +} diff --git a/config/suppliers/volume-group/accessible.json b/config/suppliers/volume-group/accessible.json new file mode 100644 index 000000000..8fd399cdc --- /dev/null +++ b/config/suppliers/volume-group/accessible.json @@ -0,0 +1,8 @@ +{ + "description": "Accessible formats", + "endDate": "2028-03-31", + "id": "accessible", + "name": "Accessible", + "startDate": "2026-04-01", + "status": "DRAFT" +} diff --git a/config/suppliers/volume-group/bau.json b/config/suppliers/volume-group/bau.json new file mode 100644 index 000000000..238f3a4a9 --- /dev/null +++ b/config/suppliers/volume-group/bau.json @@ -0,0 +1,8 @@ +{ + "description": "Combined BAU volume", + "endDate": "2028-03-31", + "id": "bau", + "name": "BAU", + "startDate": "2026-04-01", + "status": "DRAFT" +} diff --git a/config/suppliers/volume-group/datagraphic.json b/config/suppliers/volume-group/datagraphic.json new file mode 100644 index 000000000..36951e2b9 --- /dev/null +++ b/config/suppliers/volume-group/datagraphic.json @@ -0,0 +1,7 @@ +{ + "description": "PSL/Datagraphic contract", + "id": "datagraphic", + "name": "Datagraphic", + "startDate": "2026-01-01", + "status": "PROD" +} diff --git a/config/suppliers/volume-group/sameday.json b/config/suppliers/volume-group/sameday.json new file mode 100644 index 000000000..d17c520a2 --- /dev/null +++ b/config/suppliers/volume-group/sameday.json @@ -0,0 +1,8 @@ +{ + "description": "Same day dispatch", + "endDate": "2028-03-31", + "id": "sameday", + "name": "Same Day", + "startDate": "2026-04-01", + "status": "DRAFT" +} diff --git a/config/suppliers/volume-group/synertec.json b/config/suppliers/volume-group/synertec.json new file mode 100644 index 000000000..cdb63ee8a --- /dev/null +++ b/config/suppliers/volume-group/synertec.json @@ -0,0 +1,7 @@ +{ + "description": "Synertec contract", + "id": "synertec", + "name": "Synertec", + "startDate": "2026-01-01", + "status": "PROD" +} diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf index 8d089a19b..90b876a6f 100644 --- a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf +++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf @@ -2,8 +2,8 @@ resource "aws_dynamodb_table" "supplier-configuration" { name = "${local.csi}-supplier-config" billing_mode = "PAY_PER_REQUEST" - hash_key = "PK" - range_key = "SK" + hash_key = "pk" + range_key = "sk" ttl { attribute_name = "ttl" @@ -11,12 +11,12 @@ resource "aws_dynamodb_table" "supplier-configuration" { } attribute { - name = "PK" + name = "pk" type = "S" } attribute { - name = "SK" + name = "sk" type = "S" } @@ -34,13 +34,13 @@ resource "aws_dynamodb_table" "supplier-configuration" { global_secondary_index { name = "EntityTypeIndex" hash_key = "entityType" - range_key = "SK" + range_key = "sk" projection_type = "ALL" } global_secondary_index { name = "volumeGroup-index" - hash_key = "PK" + hash_key = "pk" range_key = "volumeGroup" projection_type = "ALL" } From 3fe5a229fa239792d783aee87c29f4e70b101689 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 9 Apr 2026 14:17:42 +0100 Subject: [PATCH 02/19] Fix existing database access --- internal/datastore/src/__test__/db.ts | 10 +++++----- .../__test__/supplier-config-repository.test.ts | 16 ++++++++-------- .../datastore/src/supplier-config-repository.ts | 10 +++++----- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 481166e60..9d0bf0e1e 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -151,14 +151,14 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ TableName: "supplier-config", BillingMode: "PAY_PER_REQUEST", KeySchema: [ - { AttributeName: "PK", KeyType: "HASH" }, // Partition key - { AttributeName: "SK", KeyType: "RANGE" }, // Sort key + { AttributeName: "pk", KeyType: "HASH" }, // Partition key + { AttributeName: "sk", KeyType: "RANGE" }, // Sort key ], GlobalSecondaryIndexes: [ { IndexName: "volumeGroup-index", KeySchema: [ - { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "pk", KeyType: "HASH" }, // Partition key for GSI { AttributeName: "volumeGroup", KeyType: "RANGE" }, // Sort key for GSI ], Projection: { @@ -167,8 +167,8 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ }, ], AttributeDefinitions: [ - { AttributeName: "PK", AttributeType: "S" }, - { AttributeName: "SK", AttributeType: "S" }, + { AttributeName: "pk", AttributeType: "S" }, + { AttributeName: "sk", AttributeType: "S" }, { AttributeName: "volumeGroup", AttributeType: "S" }, ], }); diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index 9fde74f94..ddd44fd4d 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -9,8 +9,8 @@ import { SupplierConfigRepository } from "../supplier-config-repository"; function createLetterVariantItem(variantId: string) { return { - PK: "LETTER_VARIANT", - SK: variantId, + pk: "ENTITY#letter-variant", + sk: `ID#${variantId}`, id: variantId, name: `Variant ${variantId}`, description: `Description for variant ${variantId}`, @@ -29,8 +29,8 @@ function createVolumeGroupItem(groupId: string, status = "PROD") { .toISOString() .split("T")[0]; // Ends in a day to ensure it's active based on end date. Tests can override this if needed. return { - PK: "VOLUME_GROUP", - SK: groupId, + pk: "ENTITY#volume-group", + sk: `ID#${groupId}`, id: groupId, name: `Volume Group ${groupId}`, description: `Description for volume group ${groupId}`, @@ -46,8 +46,8 @@ function createSupplierAllocationItem( supplier: string, ) { return { - PK: `SUPPLIER_ALLOCATION`, - SK: allocationId, + pk: "ENTITY#supplier-allocation", + sk: `ID#${allocationId}`, id: allocationId, status: "PROD", volumeGroup: groupId, @@ -58,8 +58,8 @@ function createSupplierAllocationItem( function createSupplierItem(supplierId: string) { return { - PK: "SUPPLIER", - SK: supplierId, + pk: "ENTITY#supplier", + sk: `ID#${supplierId}`, id: supplierId, name: `Supplier ${supplierId}`, channelType: "LETTER", diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index 1f82bb0c2..4eeeddb10 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -28,7 +28,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { PK: "LETTER_VARIANT", SK: variantId }, + Key: { pk: "ENTITY#letter-variant", sk: `ID#${variantId}` }, }), ); if (!result.Item) { @@ -42,7 +42,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { PK: "VOLUME_GROUP", SK: groupId }, + Key: { pk: "ENTITY#volume-group", sk: `ID#${groupId}` }, }), ); if (!result.Item) { @@ -61,12 +61,12 @@ export class SupplierConfigRepository { KeyConditionExpression: "#pk = :pk AND #group = :groupId", FilterExpression: "#status = :status ", ExpressionAttributeNames: { - "#pk": "PK", + "#pk": "pk", "#group": "volumeGroup", "#status": "status", }, ExpressionAttributeValues: { - ":pk": "SUPPLIER_ALLOCATION", + ":pk": "ENTITY#supplier-allocation", ":groupId": groupId, ":status": "PROD", }, @@ -87,7 +87,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { PK: "SUPPLIER", SK: supplierId }, + Key: { pk: "ENTITY#supplier", sk: `ID#${supplierId}` }, }), ); if (!result.Item) { From 117b0ab7d995600ca1d0954cd508623427dc7f82 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 9 Apr 2026 15:07:17 +0100 Subject: [PATCH 03/19] Initial config dev test data --- .github/workflows/stage-3-build.yaml | 3 +- .../letter-variant/notify-standard-test1.json | 29 +++++++++++++++++++ .../supplier1-volumeGroup-test1.json | 7 +++++ .../supplier2-volumeGroup-test1.json | 7 +++++ .../volume-group/volumeGroup-test1.json | 7 +++++ 5 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 config/suppliers/letter-variant/notify-standard-test1.json create mode 100644 config/suppliers/supplier-allocation/supplier1-volumeGroup-test1.json create mode 100644 config/suppliers/supplier-allocation/supplier2-volumeGroup-test1.json create mode 100644 config/suppliers/volume-group/volumeGroup-test1.json diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 516bc9473..253810a11 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -161,7 +161,8 @@ jobs: --targetAccountGroup "nhs-notify-supplier-api-dev" \ --terraformAction "apply" \ --overrideProjectName "nhs" \ - --overrideRoleName "nhs-main-acct-supplier-api-github-deploy" + --overrideRoleName "nhs-main-acct-supplier-api-github-deploy" \ + --internalRef "feature/CCM-12444-supplier-config-publishing-workflow" artefact-proxies: name: "Build proxies" runs-on: ubuntu-latest diff --git a/config/suppliers/letter-variant/notify-standard-test1.json b/config/suppliers/letter-variant/notify-standard-test1.json new file mode 100644 index 000000000..0138a738f --- /dev/null +++ b/config/suppliers/letter-variant/notify-standard-test1.json @@ -0,0 +1,29 @@ +{ + "constraints": { + "blackCoveragePercentage": { + "operator": "LESS_THAN", + "value": 20 + }, + "deliveryDays": { + "operator": "LESS_THAN", + "value": 3 + }, + "sheets": { + "operator": "LESS_THAN", + "value": 5 + }, + "sides": { + "operator": "LESS_THAN", + "value": 10 + } + }, + "description": "Dev test variant for happy path testing", + "id": "notify-standard-test1", + "name": "Dev Happy Path", + "packSpecificationIds": [ + "notify-c5" + ], + "status": "PROD", + "type": "STANDARD", + "volumeGroupId": "volumeGroup-test1" +} diff --git a/config/suppliers/supplier-allocation/supplier1-volumeGroup-test1.json b/config/suppliers/supplier-allocation/supplier1-volumeGroup-test1.json new file mode 100644 index 000000000..81a82b31b --- /dev/null +++ b/config/suppliers/supplier-allocation/supplier1-volumeGroup-test1.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 30, + "id": "supplier1-volumeGroup-test1", + "status": "PROD", + "supplier": "supplier1", + "volumeGroup": "volumeGroup-test1" +} diff --git a/config/suppliers/supplier-allocation/supplier2-volumeGroup-test1.json b/config/suppliers/supplier-allocation/supplier2-volumeGroup-test1.json new file mode 100644 index 000000000..9d34abba3 --- /dev/null +++ b/config/suppliers/supplier-allocation/supplier2-volumeGroup-test1.json @@ -0,0 +1,7 @@ +{ + "allocationPercentage": 70, + "id": "supplier2-volumeGroup-test1", + "status": "PROD", + "supplier": "supplier2", + "volumeGroup": "volumeGroup-test1" +} diff --git a/config/suppliers/volume-group/volumeGroup-test1.json b/config/suppliers/volume-group/volumeGroup-test1.json new file mode 100644 index 000000000..3b3bab804 --- /dev/null +++ b/config/suppliers/volume-group/volumeGroup-test1.json @@ -0,0 +1,7 @@ +{ + "description": "Dev Test Volume Group 1", + "id": "volumeGroup-test1", + "name": "Dev Test Volume Group 1", + "startDate": "2026-01-01", + "status": "PROD" +} From d5acfd5ca1c1df2107c05bf498a1b4628622faec Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 14 Apr 2026 09:10:31 +0100 Subject: [PATCH 04/19] disable integration tests for now --- tests/config/main.config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/config/main.config.ts b/tests/config/main.config.ts index 3a371f324..b2146c7ad 100644 --- a/tests/config/main.config.ts +++ b/tests/config/main.config.ts @@ -25,11 +25,11 @@ const localConfig: PlaywrightTestConfig = { testDir: path.resolve(__dirname, "../component-tests/letterQueue-tests"), testMatch: "**/*.spec.ts", }, - { - name: "integration-tests", - testDir: path.resolve(__dirname, "../component-tests/integration-tests"), - testMatch: "**/*.spec.ts", - }, + // { + // name: "integration-tests", + // testDir: path.resolve(__dirname, "../component-tests/integration-tests"), + // testMatch: "**/*.spec.ts", + // }, ], }; From 3f66c9433ed121813deaef8c3cd4e2533d9297f0 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 14 Apr 2026 10:29:28 +0100 Subject: [PATCH 05/19] add force --- .github/scripts/dispatch_internal_repo_workflow.sh | 11 +++++++++++ .github/workflows/stage-3-build.yaml | 3 ++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/scripts/dispatch_internal_repo_workflow.sh b/.github/scripts/dispatch_internal_repo_workflow.sh index 599292976..0a08d3002 100755 --- a/.github/scripts/dispatch_internal_repo_workflow.sh +++ b/.github/scripts/dispatch_internal_repo_workflow.sh @@ -108,6 +108,10 @@ while [[ $# -gt 0 ]]; do tableName="$2" shift 2 ;; + --force) # Force apply flag (optional) + force="$2" + shift 2 + ;; *) echo "[ERROR] Unknown argument: $1" exit 1 @@ -210,6 +214,10 @@ if [{ -z "$tableName" }]; then tableName="" fi +if [[ -z "$force" ]]; then + force="" +fi + echo "==================== Workflow Dispatch Parameters ====================" echo " infraRepoName: $infraRepoName" echo " releaseVersion: $releaseVersion" @@ -230,6 +238,7 @@ echo " boundedContext: $boundedContext" echo " targetDomain: $targetDomain" echo " version: $version" echo " tableName: $tableName" +echo " force: $force" DISPATCH_EVENT=$(jq -ncM \ --arg infraRepoName "$infraRepoName" \ @@ -250,6 +259,7 @@ DISPATCH_EVENT=$(jq -ncM \ --arg targetDomain "$targetDomain" \ --arg version "$version" \ --arg tableName "$tableName" \ + --arg force "$force" \ '{ "ref": "'"$internalRef"'", "inputs": ( @@ -266,6 +276,7 @@ DISPATCH_EVENT=$(jq -ncM \ (if $targetDomain != "" then { "targetDomain": $targetDomain } else {} end) + (if $version != "" then { "version": $version } else {} end) + (if $tableName != "" then { "tableName": $tableName } else {} end) + + (if $force != "" then { "force": $force } else {} end) + (if $targetAccountGroup != "" then { "targetAccountGroup": $targetAccountGroup } else {} end) + { "releaseVersion": $releaseVersion, diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 253810a11..1cbb78848 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -212,4 +212,5 @@ jobs: --targetComponent "config" \ --targetAccountGroup "nhs-notify-suppliers-dev" \ --tableName "supplier-config" \ - --internalRef "feature/CCM-12444-supplier-config-publishing-workflow" + --internalRef "feature/CCM-12444-supplier-config-publishing-workflow" \ + --force "false" From 4b6ae4931553fdbfd342e27ff78e6cd066572447 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 19 Mar 2026 15:34:59 +0000 Subject: [PATCH 06/19] CCM-13372 - Select Preferred Pack --- .../api/ddb_table_supplier_configuration.tf | 12 ++ .../api/module_lambda_supplier_allocator.tf | 3 +- internal/datastore/src/__test__/db.ts | 11 + .../supplier-config-repository.test.ts | 82 ++++++++ .../src/supplier-config-repository.ts | 39 ++++ .../__tests__/allocate-handler.test.ts | 13 +- .../src/handler/allocate-handler.ts | 38 +++- .../__tests__/supplier-config.test.ts | 188 ++++++++++++++---- .../src/services/supplier-config.ts | 73 ++++++- 9 files changed, 411 insertions(+), 48 deletions(-) diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf index 90b876a6f..c43e4cb2c 100644 --- a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf +++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf @@ -30,6 +30,11 @@ resource "aws_dynamodb_table" "supplier-configuration" { type = "S" } + attribute { + name = "packSpecificationId" + type = "S" + } + // The type-index GSI allows us to query for all supplier configurations of a given type (e.g. all letter supplier configurations) global_secondary_index { name = "EntityTypeIndex" @@ -45,6 +50,13 @@ resource "aws_dynamodb_table" "supplier-configuration" { projection_type = "ALL" } + global_secondary_index { + name = "packSpecificationId-index" + hash_key = "PK" + range_key = "packSpecificationId" + projection_type = "ALL" + } + point_in_time_recovery { enabled = true } diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index b568307c9..c2013fb6f 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -94,8 +94,7 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { resources = [ aws_dynamodb_table.supplier-configuration.arn, - "${aws_dynamodb_table.supplier-configuration.arn}/index/volumeGroup-index" - + "${aws_dynamodb_table.supplier-configuration.arn}/index/*" ] } } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 9d0bf0e1e..6f50af34d 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -165,11 +165,22 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ ProjectionType: "ALL", }, }, + { + IndexName: "packSpecificationId-index", + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "packSpecificationId", KeyType: "RANGE" }, // Sort key for GSI + ], + Projection: { + ProjectionType: "ALL", + }, + }, ], AttributeDefinitions: [ { AttributeName: "pk", AttributeType: "S" }, { AttributeName: "sk", AttributeType: "S" }, { AttributeName: "volumeGroup", AttributeType: "S" }, + { AttributeName: "packSpecificationId", AttributeType: "S" }, ], }); diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index ddd44fd4d..b2beea82e 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -263,4 +263,86 @@ describe("SupplierConfigRepository", () => { `Supplier with id ${supplierId} not found`, ); }); + + test("getSupplierPacksForPackSpecification returns correct supplier packs", async () => { + const packSpecId = "pack-spec-123"; + const supplierId = "supplier-123"; + const supplierPackId = "supplier-pack-123"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: { + PK: "SUPPLIER_PACK", + SK: supplierPackId, + id: supplierPackId, + packSpecificationId: packSpecId, + supplierId, + status: "PROD", + approval: "APPROVED", + }, + }), + ); + + const result = + await repository.getSupplierPacksForPackSpecification(packSpecId); + expect(result).toEqual([ + { + approval: "APPROVED", + id: supplierPackId, + packSpecificationId: packSpecId, + supplierId, + status: "PROD", + }, + ]); + }); + + test("getSupplierPacksForPackSpecification returns empty array for non-existent pack specification", async () => { + const packSpecId = "non-existent-pack-spec"; + const result = + await repository.getSupplierPacksForPackSpecification(packSpecId); + expect(result).toEqual([]); + }); + + test("getPackSpecification returns correct pack specification details", async () => { + const packSpecId = "pack-spec-123"; + + await dbContext.docClient.send( + new PutCommand({ + TableName: dbContext.config.supplierConfigTableName, + Item: { + PK: "PACK_SPECIFICATION", + SK: packSpecId, + id: packSpecId, + name: `Pack Specification ${packSpecId}`, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + billingId: `billing-${packSpecId}`, + postage: { id: "postageId", size: "STANDARD" }, + status: "PROD", + }, + }), + ); + + const result = await repository.getPackSpecification(packSpecId); + expect(result).toEqual({ + billingId: `billing-${packSpecId}`, + createdAt: expect.any(String), + id: packSpecId, + name: `Pack Specification ${packSpecId}`, + postage: { id: "postageId", size: "STANDARD" }, + updatedAt: expect.any(String), + version: 1, + status: "PROD", + }); + }); + + test("getPackSpecification throws error for non-existent pack specification", async () => { + const packSpecId = "non-existent-pack-spec"; + + await expect(repository.getPackSpecification(packSpecId)).rejects.toThrow( + `No pack specification found for id ${packSpecId}`, + ); + }); }); diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index 4eeeddb10..ac6be53b4 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -5,12 +5,16 @@ import { } from "@aws-sdk/lib-dynamodb"; import { $LetterVariant, + $PackSpecification, $Supplier, $SupplierAllocation, + $SupplierPack, $VolumeGroup, LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; @@ -97,4 +101,39 @@ export class SupplierConfigRepository { } return suppliers; } + + async getSupplierPacksForPackSpecification( + packSpecId: string, + ): Promise { + const result = await this.ddbClient.send( + new QueryCommand({ + TableName: this.config.supplierConfigTableName, + IndexName: "packSpecificationId-index", + KeyConditionExpression: "#pk = :pk AND #packSpecId = :packSpecId", + ExpressionAttributeNames: { + "#pk": "PK", + "#packSpecId": "packSpecificationId", + }, + ExpressionAttributeValues: { + ":pk": "SUPPLIER_PACK", + ":packSpecId": packSpecId, + }, + }), + ); + + return $SupplierPack.array().parse(result.Items); + } + + async getPackSpecification(packSpecId: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierConfigTableName, + Key: { PK: "PACK_SPECIFICATION", SK: packSpecId }, + }), + ); + if (!result.Item) { + throw new Error(`No pack specification found for id ${packSpecId}`); + } + return $PackSpecification.parse(result.Item); + } } diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index eb1a3bfdb..0b7f452f1 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -1,6 +1,6 @@ +import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import { SQSEvent, SQSRecord } from "aws-lambda"; import pino from "pino"; -import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { @@ -158,6 +158,17 @@ function setupDefaultMocks() { priority: 1, billingId: "billing-1", }); + (supplierConfig.getPreferredSupplierPacks as jest.Mock).mockResolvedValue([ + { + packSpecificationId: "pack-spec-1", + }, + ]); + (supplierConfig.getPackSpecification as jest.Mock).mockResolvedValue({ + id: "pack-spec-1", + type: "A4", + colour: false, + duplex: false, + }); } describe("createSupplierAllocatorHandler", () => { diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 2288fae12..ebfede749 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -3,8 +3,10 @@ import { SendMessageCommand } from "@aws-sdk/client-sqs"; import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; import { LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; @@ -12,8 +14,11 @@ import z from "zod"; import { Unit } from "aws-embedded-metrics"; import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers"; import { + getPackSpecification, + getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, + getSuppliersWithValidPack, getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; @@ -83,19 +88,44 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { variantDetails.supplierId, ); - const supplierDetails: Supplier[] = await getSupplierDetails( - supplierAllocations, + const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); + + const allocatedSuppliers: Supplier[] = await getSupplierDetails( + supplierIds, deps, ); + + const preferredSupplierPacks: SupplierPack[] = + await getPreferredSupplierPacks( + variantDetails.packSpecificationIds, + allocatedSuppliers, + deps, + ); + + const preferredPack: PackSpecification = await getPackSpecification( + preferredSupplierPacks[0].packSpecificationId, + deps, + ); + + const suppliersForPack: Supplier[] = await getSuppliersWithValidPack( + allocatedSuppliers, + preferredPack.id, + deps, + ); + deps.logger.info({ description: "Fetched supplier details for supplier allocations", variantId: letterEvent.data.letterVariantId, volumeGroupId: volumeGroupDetails.id, supplierAllocationIds: supplierAllocations.map((a) => a.id), - supplierDetails, + allocatedSuppliers, + eligiblePacks: variantDetails.packSpecificationIds, + preferredSupplierPacks, + preferredPack, + suppliersForPack, }); - return supplierDetails; + return allocatedSuppliers; } catch (error) { deps.logger.error({ description: "Error fetching supplier from config", diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index 7941d1f08..08cee6bea 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -1,6 +1,9 @@ import { + getPackSpecification, + getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, getSupplierDetails, + getSuppliersWithValidPack, getVariantDetails, getVolumeGroupDetails, } from "../supplier-config"; @@ -31,7 +34,7 @@ describe("supplier-config service", () => { afterEach(() => jest.resetAllMocks()); describe("getVariantDetails", () => { - it("returns variant details", async () => { + it("returns variant details for valid id", async () => { const variant = { id: "v1", volumeGroupId: "g1" } as any; const deps = makeDeps(); deps.supplierConfigRepo.getLetterVariant = jest @@ -188,10 +191,7 @@ describe("supplier-config service", () => { describe("getSupplierDetails", () => { it("returns supplier details when found", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; + const supplierIds = ["s1", "s2"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "PROD" }, @@ -201,7 +201,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - const result = await getSupplierDetails(allocations, deps); + const result = await getSupplierDetails(supplierIds, deps); expect(result).toEqual(suppliers); expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ @@ -211,23 +211,19 @@ describe("supplier-config service", () => { }); it("throws when no supplier details found", async () => { - const allocations = [{ supplier: "s1", variantId: "v1" }] as any[]; + const supplierIds = ["s1"]; const deps = makeDeps(); deps.supplierConfigRepo.getSuppliersDetails = jest .fn() .mockResolvedValue([]); - await expect(getSupplierDetails(allocations, deps)).rejects.toThrow( + await expect(getSupplierDetails(supplierIds, deps)).rejects.toThrow( /No supplier details found/, ); }); it("extracts supplier ids from allocations and requests details", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s3", variantId: "v2" }, - { supplier: "s5", variantId: "v3" }, - ] as any[]; + const supplierIds = ["s1", "s3", "s5"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s3", name: "Supplier 3", status: "PROD" }, @@ -238,7 +234,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.supplierConfigRepo.getSuppliersDetails).toHaveBeenCalledWith([ "s1", @@ -248,11 +244,7 @@ describe("supplier-config service", () => { }); }); it("logs a warning when supplier allocations count differs from supplier details count", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - { supplier: "s3", variantId: "v3" }, - ] as any[]; + const supplierIds = ["s1", "s2", "s3"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "PROD" }, @@ -262,7 +254,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.logger.warn).toHaveBeenCalledWith({ description: "Mismatch between supplier allocations and supplier details", @@ -273,10 +265,7 @@ describe("supplier-config service", () => { }); it("does not log a warning when counts match", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; + const supplierIds = ["s1", "s2"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "PROD" }, @@ -286,16 +275,13 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await getSupplierDetails(allocations, deps); + await getSupplierDetails(supplierIds, deps); expect(deps.logger.warn).not.toHaveBeenCalled(); }); it("throws when no active suppliers found", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - ] as any[]; + const supplierIds = ["s1", "s2"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "DRAFT" }, { id: "s2", name: "Supplier 2", status: "DRAFT" }, @@ -305,7 +291,7 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - await expect(getSupplierDetails(allocations, deps)).rejects.toThrow( + await expect(getSupplierDetails(supplierIds, deps)).rejects.toThrow( /No active suppliers found/, ); expect(deps.logger.error).toHaveBeenCalledWith( @@ -316,11 +302,7 @@ describe("supplier-config service", () => { }); it("filters to return only active suppliers with PROD status", async () => { - const allocations = [ - { supplier: "s1", variantId: "v1" }, - { supplier: "s2", variantId: "v2" }, - { supplier: "s3", variantId: "v3" }, - ] as any[]; + const supplierIds = ["s1", "s2", "s3"]; const suppliers = [ { id: "s1", name: "Supplier 1", status: "PROD" }, { id: "s2", name: "Supplier 2", status: "DRAFT" }, @@ -331,9 +313,143 @@ describe("supplier-config service", () => { .fn() .mockResolvedValue(suppliers); - const result = await getSupplierDetails(allocations, deps); + const result = await getSupplierDetails(supplierIds, deps); expect(result).toEqual([suppliers[0], suppliers[2]]); expect(result.every((s) => s.status === "PROD")).toBe(true); }); + describe("getPreferredSupplierPacks", () => { + it("returns preferred supplier packs when found", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + { id: "p3", supplierId: "s3", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + const result = await getPreferredSupplierPacks( + ["spec1"], + suppliers, + deps, + ); + + expect(result).toEqual([ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + ]); + }); + + it("throws when no preferred supplier packs found", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue([]); + + await expect( + getPreferredSupplierPacks(["spec1"], suppliers, deps), + ).rejects.toThrow(/No preferred supplier packs found/); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "No preferred supplier packs found for pack specification ids and suppliers", + }), + ); + }); + it("does not error when at least 1 pack specification has a preferred supplier pack", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValueOnce([]) // no packs for spec1 + .mockResolvedValueOnce([ + { id: "p2", supplierId: "s2", packSpecificationId: "spec2" }, + ]); // preferred pack for spec2 + + const result = await getPreferredSupplierPacks( + ["spec1", "spec2"], + suppliers, + deps, + ); + + expect(result).toEqual([ + { id: "p2", supplierId: "s2", packSpecificationId: "spec2" }, + ]); + }); + + it("throws an error when no suppliers match the pack specification", async () => { + const suppliers = [ + { id: "s4", name: "Supplier 1", status: "PROD" }, + { id: "s5", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + { id: "p2", supplierId: "s2", packSpecificationId: "spec1" }, + { id: "p3", supplierId: "s3", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + await expect( + getPreferredSupplierPacks(["spec1"], suppliers, deps), + ).rejects.toThrow(/No preferred supplier packs found/); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: + "No preferred supplier packs found for pack specification ids and suppliers", + }), + ); + }); + }); + + describe("getPackSpecification", () => { + it("returns pack specification when found", async () => { + const packSpec = { id: "spec1", name: "Pack Spec 1" } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue(packSpec); + + const result = await getPackSpecification("spec1", deps); + + expect(result).toBe(packSpec); + }); + }); + + describe("getSuppliersWithValidPack", () => { + it("returns suppliers that have the valid pack specification", async () => { + const suppliers = [ + { id: "s1", name: "Supplier 1", status: "PROD" }, + { id: "s2", name: "Supplier 2", status: "PROD" }, + ] as any[]; + const supplierPacks = [ + { id: "p1", supplierId: "s1", packSpecificationId: "spec1" }, + ] as any[]; + const deps = makeDeps(); + deps.supplierConfigRepo.getSupplierPacksForPackSpecification = jest + .fn() + .mockResolvedValue(supplierPacks); + + const result = await getSuppliersWithValidPack(suppliers, "spec1", deps); + + expect(result).toEqual([ + { id: "s1", name: "Supplier 1", status: "PROD" }, + ]); + }); + }); }); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 9710a68bd..87d750aa4 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -1,7 +1,9 @@ import { LetterVariant, + PackSpecification, Supplier, SupplierAllocation, + SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { Deps } from "../config/deps"; @@ -75,11 +77,9 @@ export async function getSupplierAllocationsForVolumeGroup( } export async function getSupplierDetails( - supplierAllocations: SupplierAllocation[], + supplierIds: string[], deps: Deps, ): Promise { - const supplierIds = supplierAllocations.map((alloc) => alloc.supplier); - const supplierDetails: Supplier[] = await deps.supplierConfigRepo.getSuppliersDetails(supplierIds); @@ -93,14 +93,14 @@ export async function getSupplierDetails( ); } // Log a warning if some supplier details are missing compared to allocations - if (supplierAllocations.length !== supplierDetails.length) { + if (supplierIds.length !== supplierDetails.length) { const foundSupplierIds = new Set(supplierDetails.map((s) => s.id)); const missingSupplierIds = supplierIds.filter( (id) => !foundSupplierIds.has(id), ); deps.logger.warn({ description: "Mismatch between supplier allocations and supplier details", - allocationsCount: supplierAllocations.length, + allocationsCount: supplierIds.length, detailsCount: supplierDetails.length, missingSuppliers: missingSupplierIds, }); @@ -117,3 +117,66 @@ export async function getSupplierDetails( } return activeSuppliers; } + +export async function getPreferredSupplierPacks( + packSpecificationIds: string[], + suppliers: Supplier[], + deps: Deps, +): Promise { + for (const packSpecId of packSpecificationIds) { + const supplierPacks = + await deps.supplierConfigRepo.getSupplierPacksForPackSpecification( + packSpecId, + ); + if (supplierPacks.length > 0) { + const preferredPacks = supplierPacks.filter((pack) => + suppliers.some((supplier) => supplier.id === pack.supplierId), + ); + if (preferredPacks.length > 0) { + return preferredPacks; + } + } + } + deps.logger.error({ + description: + "No preferred supplier packs found for pack specification ids and suppliers", + packSpecificationIds, + supplierIds: suppliers.map((s) => s.id), + }); + throw new Error( + `No preferred supplier packs found for pack specification ids ${packSpecificationIds.join(", ")} and suppliers ${suppliers.map((s) => s.id).join(", ")}`, + ); +} + +export async function getPackSpecification( + packSpecId: string, + deps: Deps, +): Promise { + const packSpec = + await deps.supplierConfigRepo.getPackSpecification(packSpecId); + return packSpec; +} + +// This function is used to filter the allocated suppliers based on those that support the supplied pack specification +export async function getSuppliersWithValidPack( + suppliers: Supplier[], + packSpecificationId: string, + deps: Deps, +): Promise { + const suppliersWithValidPack: Supplier[] = []; + const supplierPacks = + await deps.supplierConfigRepo.getSupplierPacksForPackSpecification( + packSpecificationId, + ); + + for (const supplier of suppliers) { + const hasValidPack = supplierPacks.some( + (pack) => pack.supplierId === supplier.id, + ); + if (hasValidPack) { + suppliersWithValidPack.push(supplier); + } + } + + return suppliersWithValidPack; +} From df0e5666c131cfdf422805b0b497e8ab6e51d191 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 27 Mar 2026 13:22:49 +0000 Subject: [PATCH 07/19] added more validity checks --- .../src/supplier-config-repository.ts | 5 ++++ .../__tests__/supplier-config.test.ts | 29 ++++++++++++++++++- .../src/services/supplier-config.ts | 8 +++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index ac6be53b4..d7a644b52 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -110,13 +110,18 @@ export class SupplierConfigRepository { TableName: this.config.supplierConfigTableName, IndexName: "packSpecificationId-index", KeyConditionExpression: "#pk = :pk AND #packSpecId = :packSpecId", + FilterExpression: "#status = :status AND #approval = :approval", ExpressionAttributeNames: { "#pk": "PK", "#packSpecId": "packSpecificationId", + "#status": "status", + "#approval": "approval", }, ExpressionAttributeValues: { ":pk": "SUPPLIER_PACK", ":packSpecId": packSpecId, + ":status": "PROD", + ":approval": "APPROVED", }, }), ); diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index 08cee6bea..f0ba4d4c9 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -419,7 +419,11 @@ describe("supplier-config service", () => { describe("getPackSpecification", () => { it("returns pack specification when found", async () => { - const packSpec = { id: "spec1", name: "Pack Spec 1" } as any; + const packSpec = { + id: "spec1", + name: "Pack Spec 1", + status: "PROD", + } as any; const deps = makeDeps(); deps.supplierConfigRepo.getPackSpecification = jest .fn() @@ -429,6 +433,29 @@ describe("supplier-config service", () => { expect(result).toBe(packSpec); }); + + it("throws when pack specification is not active based on status", async () => { + const packSpec = { + id: "spec2", + name: "Pack Spec 2", + status: "DRAFT", + } as any; + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue(packSpec); + + await expect(getPackSpecification("spec2", deps)).rejects.toThrow( + /not active/, + ); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: "Pack specification is not active based on status", + packSpecId: "spec2", + status: "DRAFT", + }), + ); + }); }); describe("getSuppliersWithValidPack", () => { diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 87d750aa4..31db9660f 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -154,6 +154,14 @@ export async function getPackSpecification( ): Promise { const packSpec = await deps.supplierConfigRepo.getPackSpecification(packSpecId); + if (packSpec.status !== "PROD") { + deps.logger.error({ + description: "Pack specification is not active based on status", + packSpecId, + status: packSpec.status, + }); + throw new Error(`Pack specification with id ${packSpecId} is not active`); + } return packSpec; } From 7579815086cfcb38b0e96f3ef8cca15f27dd5119 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 7 Apr 2026 10:40:45 +0100 Subject: [PATCH 08/19] CCM-13371 - Determine Eligible packs --- lambdas/supplier-allocator/.tool-versions | 1 + .../src/handler/allocate-handler.ts | 16 ++- .../__tests__/supplier-config.test.ts | 126 ++++++++++++++++++ .../src/services/supplier-config.ts | 82 ++++++++++++ 4 files changed, 219 insertions(+), 6 deletions(-) create mode 100644 lambdas/supplier-allocator/.tool-versions diff --git a/lambdas/supplier-allocator/.tool-versions b/lambdas/supplier-allocator/.tool-versions new file mode 100644 index 000000000..a3128f26b --- /dev/null +++ b/lambdas/supplier-allocator/.tool-versions @@ -0,0 +1 @@ +nodejs 22.22.0 diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index ebfede749..7475b9565 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -14,6 +14,7 @@ import z from "zod"; import { Unit } from "aws-embedded-metrics"; import { MetricEntry, MetricStatus, buildEMFObject } from "@internal/helpers"; import { + filterPacksForLetter, getPackSpecification, getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, @@ -95,12 +96,14 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { deps, ); + const eligiblePacks: string[] = await filterPacksForLetter( + letterEvent, + variantDetails.packSpecificationIds, + deps, + ); + const preferredSupplierPacks: SupplierPack[] = - await getPreferredSupplierPacks( - variantDetails.packSpecificationIds, - allocatedSuppliers, - deps, - ); + await getPreferredSupplierPacks(eligiblePacks, allocatedSuppliers, deps); const preferredPack: PackSpecification = await getPackSpecification( preferredSupplierPacks[0].packSpecificationId, @@ -119,7 +122,8 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { volumeGroupId: volumeGroupDetails.id, supplierAllocationIds: supplierAllocations.map((a) => a.id), allocatedSuppliers, - eligiblePacks: variantDetails.packSpecificationIds, + variantPacks: variantDetails.packSpecificationIds, + eligiblePacks, preferredSupplierPacks, preferredPack, suppliersForPack, diff --git a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts index f0ba4d4c9..ac6b468d5 100644 --- a/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts +++ b/lambdas/supplier-allocator/src/services/__tests__/supplier-config.test.ts @@ -1,4 +1,5 @@ import { + filterPacksForLetter, getPackSpecification, getPreferredSupplierPacks, getSupplierAllocationsForVolumeGroup, @@ -479,4 +480,129 @@ describe("supplier-config service", () => { ]); }); }); + + describe("filterPacksForLetter", () => { + it("returns eligible packs for letter", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { operator: "LESS_THAN", value: 2 }, + }, + } as any); + const letterEvent = { + data: { + pageCount: 1, + }, + } as any; + + const result = await filterPacksForLetter(letterEvent, ["spec1"], deps); + + expect(result).toEqual(["spec1"]); + }); + it("throws when no eligible packs found for letter", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { operator: "LESS_THAN", value: 2 }, + }, + } as any); + const letterEvent = { + data: { + pageCount: 3, + }, + } as any; + + await expect( + filterPacksForLetter(letterEvent, ["spec1"], deps), + ).rejects.toThrow( + "No eligible pack specifications found for letter variant id undefined and pack specification ids spec1", + ); + expect(deps.logger.error).toHaveBeenCalledWith( + expect.objectContaining({ + description: "No eligible pack specifications found for letter", + letterVariantId: undefined, + packSpecificationIds: ["spec1"], + }), + ); + }); + it("returns eligible packs for all constraint types", async () => { + const deps = makeDeps(); + const constraints: { operator: string; value: number }[] = [ + { operator: "EQUALS", value: 2 }, + { operator: "NOT_EQUALS", value: 1 }, + { operator: "GREATER_THAN", value: 1 }, + { operator: "LESS_THAN", value: 3 }, + { operator: "GREATER_THAN_OR_EQUAL", value: 2 }, + { operator: "LESS_THAN_OR_EQUAL", value: 2 }, + ]; + const letterEvent = { + data: { + pageCount: 2, + }, + } as any; + + for (const constraint of constraints) { + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { + operator: constraint.operator, + value: constraint.value, + }, + }, + } as any); + + const result = await filterPacksForLetter(letterEvent, ["spec1"], deps); + + expect(result).toEqual(["spec1"]); + } + }); + it("throws an error for unsupported operator", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + constraints: { + sheets: { operator: "UNSUPPORTED_OP", value: 2 }, + }, + } as any); + const letterEvent = { + data: { + pageCount: 2, + }, + } as any; + + await expect( + filterPacksForLetter(letterEvent, ["spec1"], deps), + ).rejects.toThrow( + "Unsupported operator UNSUPPORTED_OP in pack specification constraints", + ); + }); + it("returns all packs when no constraints defined", async () => { + const deps = makeDeps(); + deps.supplierConfigRepo.getPackSpecification = jest + .fn() + .mockResolvedValue({ + id: "spec1", + } as any); + const letterEvent = { + data: { + pageCount: 5, + }, + } as any; + + const result = await filterPacksForLetter(letterEvent, ["spec1"], deps); + + expect(result).toEqual(["spec1"]); + }); + }); }); diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 31db9660f..c3d8c2127 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -6,8 +6,13 @@ import { SupplierPack, VolumeGroup, } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { LetterRequestPreparedEventV2 } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering"; +import { LetterRequestPreparedEvent } from "@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1"; + import { Deps } from "../config/deps"; +type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; + export async function getVariantDetails( variantId: string, deps: Deps, @@ -188,3 +193,80 @@ export async function getSuppliersWithValidPack( return suppliersWithValidPack; } + +function evaluateContraint( + actualValue: number, + constraintValue: number, + operator: string, +): boolean { + console.log( + `Evaluating constraint: actualValue ${actualValue}, constraintValue ${constraintValue}, operator ${operator}`, + ); + switch (operator) { + case "EQUALS": { + return actualValue === constraintValue; + } + case "NOT_EQUALS": { + return actualValue !== constraintValue; + } + case "GREATER_THAN": { + return actualValue > constraintValue; + } + case "LESS_THAN": { + return actualValue < constraintValue; + } + case "GREATER_THAN_OR_EQUAL": { + return actualValue >= constraintValue; + } + case "LESS_THAN_OR_EQUAL": { + return actualValue <= constraintValue; + } + default: { + throw new Error( + `Unsupported operator ${operator} in pack specification constraints`, + ); + } + } +} + +// This function is used to filter the pack specifications for a letter based on the letter data pages and pack specification constraints sheets + +export async function filterPacksForLetter( + letterEvent: PreparedEvents, + packSpecificationIds: string[], + deps: Deps, +): Promise { + const filteredPackIds: string[] = []; + for (const packSpecId of packSpecificationIds) { + const packSpec = + await deps.supplierConfigRepo.getPackSpecification(packSpecId); + if ( + !packSpec.constraints || + !packSpec.constraints.sheets || + !packSpec.constraints.sheets.value || + !packSpec.constraints.sheets.operator + ) { + filteredPackIds.push(packSpecId); + } else { + const isValid = evaluateContraint( + letterEvent.data.pageCount, + packSpec.constraints.sheets.value, + packSpec.constraints.sheets.operator, + ); + if (isValid) { + filteredPackIds.push(packSpecId); + } + } + } + if (filteredPackIds.length === 0) { + deps.logger.error({ + description: "No eligible pack specifications found for letter", + letterVariantId: letterEvent.data.letterVariantId, + packSpecificationIds, + }); + throw new Error( + `No eligible pack specifications found for letter variant id ${letterEvent.data.letterVariantId} and pack specification ids ${packSpecificationIds.join(", ")}`, + ); + } + return filteredPackIds; +} From 01c4ada63b67e2ee35d23a9f4d70e44448fb1205 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 7 Apr 2026 11:29:53 +0100 Subject: [PATCH 09/19] debug logging --- .../supplier-allocator/src/services/supplier-config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index c3d8c2127..1eaa1fc48 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -248,6 +248,14 @@ export async function filterPacksForLetter( ) { filteredPackIds.push(packSpecId); } else { + deps.logger.info({ + description: "Evaluating pack specification constraints for letter", + letterVariantId: letterEvent.data.letterVariantId, + packSpecId, + pageCount: letterEvent.data.pageCount, + constraintValue: packSpec.constraints.sheets.value, + constraintOperator: packSpec.constraints.sheets.operator, + }); const isValid = evaluateContraint( letterEvent.data.pageCount, packSpec.constraints.sheets.value, From 6a84ce62bf5fa76f12dd8634900d88af20545cd5 Mon Sep 17 00:00:00 2001 From: David Wass Date: Tue, 14 Apr 2026 14:33:34 +0100 Subject: [PATCH 10/19] CCM-13372 - Update pk and sk values --- .../components/api/ddb_table_supplier_configuration.tf | 2 +- internal/datastore/src/__test__/db.ts | 2 +- .../src/__test__/supplier-config-repository.test.ts | 8 ++++---- internal/datastore/src/supplier-config-repository.ts | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf index c43e4cb2c..205c3a1b8 100644 --- a/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf +++ b/infrastructure/terraform/components/api/ddb_table_supplier_configuration.tf @@ -52,7 +52,7 @@ resource "aws_dynamodb_table" "supplier-configuration" { global_secondary_index { name = "packSpecificationId-index" - hash_key = "PK" + hash_key = "pk" range_key = "packSpecificationId" projection_type = "ALL" } diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 6f50af34d..de00a6b16 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -168,7 +168,7 @@ const createSupplierConfigTableCommand = new CreateTableCommand({ { IndexName: "packSpecificationId-index", KeySchema: [ - { AttributeName: "PK", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "pk", KeyType: "HASH" }, // Partition key for GSI { AttributeName: "packSpecificationId", KeyType: "RANGE" }, // Sort key for GSI ], Projection: { diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index b2beea82e..74bea98c3 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -273,8 +273,8 @@ describe("SupplierConfigRepository", () => { new PutCommand({ TableName: dbContext.config.supplierConfigTableName, Item: { - PK: "SUPPLIER_PACK", - SK: supplierPackId, + pk: "ENTITY#supplier-pack", + sk: `ID#${supplierPackId}`, id: supplierPackId, packSpecificationId: packSpecId, supplierId, @@ -311,8 +311,8 @@ describe("SupplierConfigRepository", () => { new PutCommand({ TableName: dbContext.config.supplierConfigTableName, Item: { - PK: "PACK_SPECIFICATION", - SK: packSpecId, + pk: "ENTITY#pack_specification", + sk: `ID#${packSpecId}`, id: packSpecId, name: `Pack Specification ${packSpecId}`, createdAt: new Date().toISOString(), diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index d7a644b52..c4648a63c 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -112,13 +112,13 @@ export class SupplierConfigRepository { KeyConditionExpression: "#pk = :pk AND #packSpecId = :packSpecId", FilterExpression: "#status = :status AND #approval = :approval", ExpressionAttributeNames: { - "#pk": "PK", + "#pk": "pk", "#packSpecId": "packSpecificationId", "#status": "status", "#approval": "approval", }, ExpressionAttributeValues: { - ":pk": "SUPPLIER_PACK", + ":pk": "ENTITY#supplier-pack", ":packSpecId": packSpecId, ":status": "PROD", ":approval": "APPROVED", @@ -133,7 +133,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { PK: "PACK_SPECIFICATION", SK: packSpecId }, + Key: { pk: "ENTITY#pack_specification", sk: `ID#${packSpecId}` }, }), ); if (!result.Item) { From e799dec43677222d34a7c8f766055a031ec2ed2f Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 16 Apr 2026 09:19:13 +0100 Subject: [PATCH 11/19] CCM-13882 - Calculate-Supplier-Weighting --- .../api/ddb_table_supplier_quotas.tf | 49 +++++++++++++++++++ .../terraform/components/api/locals.tf | 3 +- .../api/module_lambda_supplier_allocator.tf | 4 +- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf diff --git a/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf new file mode 100644 index 000000000..663b27975 --- /dev/null +++ b/infrastructure/terraform/components/api/ddb_table_supplier_quotas.tf @@ -0,0 +1,49 @@ +resource "aws_dynamodb_table" "supplier-quotas" { + name = "${local.csi}-supplier-quotas" + billing_mode = "PAY_PER_REQUEST" + + hash_key = "pk" + range_key = "sk" + + ttl { + attribute_name = "ttl" + enabled = true + } + + attribute { + name = "pk" + type = "S" + } + + attribute { + name = "sk" + type = "S" + } + + attribute { + name = "entityType" + type = "S" + } + + + + // The type-index GSI allows us to query for all supplier quotas of a given type (e.g. all supplier daily quotas) + global_secondary_index { + name = "EntityTypeIndex" + hash_key = "entityType" + range_key = "sk" + projection_type = "ALL" + } + + point_in_time_recovery { + enabled = true + } + + tags = merge( + local.default_tags, + { + NHSE-Enable-Dynamo-Backup-Acct = "True" + } + ) + +} diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf index 111925095..6975e8d4e 100644 --- a/infrastructure/terraform/components/api/locals.tf +++ b/infrastructure/terraform/components/api/locals.tf @@ -33,7 +33,8 @@ locals { MI_TABLE_NAME = aws_dynamodb_table.mi.name, MI_TTL_HOURS = 2160 # 90 days * 24 hours SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}", - SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name + SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name, + SUPPLIER_QUOTAS_TABLE_NAME = aws_dynamodb_table.supplier-quotas.name, SUPPLIER_ID_HEADER = "nhsd-supplier-id", } diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index c2013fb6f..ee9924a9d 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -94,7 +94,9 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { resources = [ aws_dynamodb_table.supplier-configuration.arn, - "${aws_dynamodb_table.supplier-configuration.arn}/index/*" + aws_dynamodb_table.supplier-quotas.arn, + "${aws_dynamodb_table.supplier-configuration.arn}/index/*", + "${aws_dynamodb_table.supplier-quotas.arn}/index/*" ] } } From 7512a20dfb1089bcee99f228730177cb68af5efb Mon Sep 17 00:00:00 2001 From: vlasis-perdikidis Date: Thu, 16 Apr 2026 16:13:42 +0000 Subject: [PATCH 12/19] add commit to gitleaksignore --- .gitleaksignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitleaksignore b/.gitleaksignore index 743843dbe..6758264a1 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -25,3 +25,9 @@ debc75a97cfe551a69fd1e8694be483213322a9d:pact-contracts/pacts/letter-rendering/s 4fa1923947bbff2387218d698d766cbb7c121a0f:pact-contracts/pacts/letter-rendering/supplier-api-letter-request-prepared.json:generic-api-key:10 d005112adcfd286c3bef076214836dbb2fe8d0b5:.npmrc:npm-access-token:9 d005112adcfd286c3bef076214836dbb2fe8d0b5:.npmrc:github-pat:7 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js.map:ipv4:4 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:63 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:62 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:60 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:59 +ff889d4c3f29da4468ecf1f05f467fe84d35b2a1:lambdas/supplier-mock/.aws-sam/build/SupplierMockFunction/index.js:ipv4:24 From edac5af35a47febeddc36ef3b33031125c9e78a9 Mon Sep 17 00:00:00 2001 From: David Wass Date: Thu, 16 Apr 2026 16:02:28 +0100 Subject: [PATCH 13/19] calculate allocation factors --- .../supplier-config-repository.test.ts | 2 +- internal/datastore/src/index.ts | 1 + .../src/supplier-config-repository.ts | 2 +- .../src/supplier-quotas-repository.ts | 155 ++++++++++++++++++ internal/datastore/src/types.ts | 39 +++++ lambdas/supplier-allocator/jest.config.ts | 2 +- .../src/config/__tests__/deps.test.ts | 10 ++ .../src/config/__tests__/env.test.ts | 2 + lambdas/supplier-allocator/src/config/deps.ts | 19 ++- lambdas/supplier-allocator/src/config/env.ts | 1 + .../__tests__/allocate-handler.test.ts | 60 ++++++- .../src/handler/allocate-handler.ts | 27 +++ .../src/services/supplier-quotas.ts | 49 ++++++ 13 files changed, 364 insertions(+), 5 deletions(-) create mode 100644 internal/datastore/src/supplier-quotas-repository.ts create mode 100644 lambdas/supplier-allocator/src/services/supplier-quotas.ts diff --git a/internal/datastore/src/__test__/supplier-config-repository.test.ts b/internal/datastore/src/__test__/supplier-config-repository.test.ts index 74bea98c3..6648b7fb6 100644 --- a/internal/datastore/src/__test__/supplier-config-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-config-repository.test.ts @@ -311,7 +311,7 @@ describe("SupplierConfigRepository", () => { new PutCommand({ TableName: dbContext.config.supplierConfigTableName, Item: { - pk: "ENTITY#pack_specification", + pk: "ENTITY#pack-specification", sk: `ID#${packSpecId}`, id: packSpecId, name: `Pack Specification ${packSpecId}`, diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 9b656d9ee..3ecd72892 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -3,6 +3,7 @@ export * from "./mi-repository"; export * from "./letter-repository"; export * from "./supplier-repository"; export * from "./supplier-config-repository"; +export * from "./supplier-quotas-repository"; export { default as LetterQueueRepository } from "./letter-queue-repository"; export { default as DBHealthcheck } from "./healthcheck"; export { default as LetterAlreadyExistsError } from "./errors/letter-already-exists-error"; diff --git a/internal/datastore/src/supplier-config-repository.ts b/internal/datastore/src/supplier-config-repository.ts index c4648a63c..46794c0c1 100644 --- a/internal/datastore/src/supplier-config-repository.ts +++ b/internal/datastore/src/supplier-config-repository.ts @@ -133,7 +133,7 @@ export class SupplierConfigRepository { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierConfigTableName, - Key: { pk: "ENTITY#pack_specification", sk: `ID#${packSpecId}` }, + Key: { pk: "ENTITY#pack-specification", sk: `ID#${packSpecId}` }, }), ); if (!result.Item) { diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts new file mode 100644 index 000000000..0231dea2c --- /dev/null +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -0,0 +1,155 @@ +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, + UpdateCommand, +} from "@aws-sdk/lib-dynamodb"; +import { + $DailyAllocation, + $OverallAllocation, + DailyAllocation, + OverallAllocation, +} from "./types"; + +export type SupplierQuotasRepositoryConfig = { + supplierQuotasTableName: string; +}; + +function ItemForRecord( + entity: string, + id: string, + record: Record, +): Record { + return { + pk: `ENTITY#${entity}`, + sk: `ID#${id}`, + ...record, + }; +} + +export class SupplierQuotasRepository { + constructor( + readonly ddbClient: DynamoDBDocumentClient, + readonly config: SupplierQuotasRepositoryConfig, + ) {} + + async getOverallAllocation(groupId: string): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, + }), + ); + if (!result.Item) { + throw new Error( + `No overall allocation found for volume group id ${groupId}`, + ); + } + return $OverallAllocation.parse(result.Item); + } + + async putOverallAllocation(allocation: OverallAllocation): Promise { + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "overall-allocation", + allocation.id, + $OverallAllocation.parse(allocation), + ), + }), + ); + } + + // Update the overallAllocation table updating the allocations array for a given volume group + // or adding the value if the supplier is not present // + async updateOverallAllocation( + groupId: string, + supplierId: string, + newAllocation: number, + ): Promise { + const overallAllocation = await this.getOverallAllocation(groupId); + const currentAllocation = overallAllocation.allocations[supplierId] ?? 0; + const updatedAllocation = currentAllocation + newAllocation; + + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { pk: "ENTITY#overall-allocation", sk: `ID#${groupId}` }, + UpdateExpression: + "SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt", + ExpressionAttributeNames: { + "#supplierId": supplierId, + }, + ExpressionAttributeValues: { + ":updatedAllocation": updatedAllocation, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } + + async getDailyAllocation( + groupId: string, + date: string, + ): Promise { + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${groupId}#DATE#${date}`, + }, + }), + ); + if (!result.Item) { + throw new Error( + `No daily allocation found for volume group id ${groupId} and date ${date}`, + ); + } + return $DailyAllocation.parse(result.Item); + } + + async putDailyAllocation(allocation: DailyAllocation): Promise { + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "daily-allocation", + `${allocation.volumeGroup}#DATE#${allocation.date}`, + $DailyAllocation.parse(allocation), + ), + }), + ); + } + + async updateDailyAllocation( + groupId: string, + date: string, + supplierId: string, + newAllocation: number, + ): Promise { + const dailyAllocation = await this.getDailyAllocation(groupId, date); + const currentAllocation = dailyAllocation.allocations[supplierId] ?? 0; + const updatedAllocation = currentAllocation + newAllocation; + + await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.supplierQuotasTableName, + Key: { + pk: "ENTITY#daily-allocation", + sk: `ID#${groupId}#DATE#${date}`, + }, + UpdateExpression: + "SET allocations.#supplierId = :updatedAllocation, updatedAt = :updatedAt", + ExpressionAttributeNames: { + "#supplierId": supplierId, + }, + ExpressionAttributeValues: { + ":updatedAllocation": updatedAllocation, + ":updatedAt": new Date().toISOString(), + }, + }), + ); + } +} diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 730f91177..25ba40d8b 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -1,5 +1,9 @@ import { z } from "zod"; import { idRef } from "@internal/helpers"; +import { + $Supplier, + $VolumeGroup, +} from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; export const SupplierStatus = z.enum(["ENABLED", "DISABLED"]); @@ -120,3 +124,38 @@ export const MISchema = MISchemaBase.extend({ export type MI = z.infer; export type MIBase = z.infer; + +export const $OverallAllocation = z + .object({ + id: z.string(), + volumeGroup: idRef($VolumeGroup, "id"), + allocations: z.record( + idRef($Supplier, "id"), + z.number().int().nonnegative(), + ), + }) + .meta({ + title: "OverallAllocation", + description: + "The overall allocation for a volume group, including all suppliers", + }); + +export type OverallAllocation = z.infer; + +export const $DailyAllocation = z + .object({ + id: z.string(), + date: z.ZodISODate, + volumeGroup: idRef($VolumeGroup, "id"), + allocations: z.record( + idRef($Supplier, "id"), + z.number().int().nonnegative(), + ), + }) + .meta({ + title: "DailyAllocation", + description: + "The daily allocation for a volume group, including all suppliers", + }); + +export type DailyAllocation = z.infer; diff --git a/lambdas/supplier-allocator/jest.config.ts b/lambdas/supplier-allocator/jest.config.ts index 872794514..9f16a04f2 100644 --- a/lambdas/supplier-allocator/jest.config.ts +++ b/lambdas/supplier-allocator/jest.config.ts @@ -14,7 +14,7 @@ export const baseJestConfig = { clearMocks: true, // Indicates whether the coverage information should be collected while executing the test - collectCoverage: true, + collectCoverage: false, // The directory where Jest should output its coverage files coverageDirectory: "./.reports/unit/coverage", diff --git a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts index 7c2767f11..6cd95d077 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/deps.test.ts @@ -3,6 +3,7 @@ import type { Deps } from "lambdas/supplier-allocator/src/config/deps"; describe("createDependenciesContainer", () => { const env = { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", @@ -30,6 +31,7 @@ describe("createDependenciesContainer", () => { // Repo client jest.mock("@internal/datastore", () => ({ SupplierConfigRepository: jest.fn(), + SupplierQuotasRepository: jest.fn(), })); // Env @@ -42,6 +44,9 @@ describe("createDependenciesContainer", () => { const { SupplierConfigRepository } = jest.requireMock( "@internal/datastore", ); + const { SupplierQuotasRepository } = jest.requireMock( + "@internal/datastore", + ); // eslint-disable-next-line @typescript-eslint/no-require-imports const { createDependenciesContainer } = require("../deps"); const deps: Deps = createDependenciesContainer(); @@ -51,6 +56,11 @@ describe("createDependenciesContainer", () => { expect(supplierConfigRepoCtorArgs[1]).toEqual({ supplierConfigTableName: "SupplierConfigTable", }); + expect(SupplierQuotasRepository).toHaveBeenCalledTimes(1); + const supplierQuotasRepoCtorArgs = SupplierQuotasRepository.mock.calls[0]; + expect(supplierQuotasRepoCtorArgs[1]).toEqual({ + supplierQuotasTableName: "SupplierQuotasTable", + }); expect(deps.env).toEqual(env); }); }); diff --git a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts index 43c4d5bb9..78e2d0a6a 100644 --- a/lambdas/supplier-allocator/src/config/__tests__/env.test.ts +++ b/lambdas/supplier-allocator/src/config/__tests__/env.test.ts @@ -16,6 +16,7 @@ describe("lambdaEnv", () => { it("should load all environment variables successfully", () => { process.env.SUPPLIER_CONFIG_TABLE_NAME = "SupplierConfigTable"; + process.env.SUPPLIER_QUOTAS_TABLE_NAME = "SupplierQuotasTable"; process.env.VARIANT_MAP = `{ "lv1": { "supplierId": "supplier1", @@ -29,6 +30,7 @@ describe("lambdaEnv", () => { expect(envVars).toEqual({ SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", diff --git a/lambdas/supplier-allocator/src/config/deps.ts b/lambdas/supplier-allocator/src/config/deps.ts index 4d51f9a07..5f58a00e0 100644 --- a/lambdas/supplier-allocator/src/config/deps.ts +++ b/lambdas/supplier-allocator/src/config/deps.ts @@ -3,11 +3,15 @@ import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { SQSClient } from "@aws-sdk/client-sqs"; import { Logger } from "pino"; import { createLogger } from "@internal/helpers"; -import { SupplierConfigRepository } from "@internal/datastore"; +import { + SupplierConfigRepository, + SupplierQuotasRepository, +} from "@internal/datastore"; import { EnvVars, envVars } from "./env"; export type Deps = { supplierConfigRepo: SupplierConfigRepository; + supplierQuotasRepo: SupplierQuotasRepository; logger: Logger; env: EnvVars; sqsClient: SQSClient; @@ -30,11 +34,24 @@ function createSupplierConfigRepository( return new SupplierConfigRepository(createDocumentClient(), config); } +function createSupplierQuotasRepository( + log: Logger, + // eslint-disable-next-line @typescript-eslint/no-shadow + envVars: EnvVars, +): SupplierQuotasRepository { + const config = { + supplierQuotasTableName: envVars.SUPPLIER_QUOTAS_TABLE_NAME, + }; + + return new SupplierQuotasRepository(createDocumentClient(), config); +} + export function createDependenciesContainer(): Deps { const log = createLogger({ logLevel: envVars.PINO_LOG_LEVEL }); return { supplierConfigRepo: createSupplierConfigRepository(log, envVars), + supplierQuotasRepo: createSupplierQuotasRepository(log, envVars), logger: log, env: envVars, sqsClient: new SQSClient({}), diff --git a/lambdas/supplier-allocator/src/config/env.ts b/lambdas/supplier-allocator/src/config/env.ts index e6959999c..657d95b88 100644 --- a/lambdas/supplier-allocator/src/config/env.ts +++ b/lambdas/supplier-allocator/src/config/env.ts @@ -13,6 +13,7 @@ export type LetterVariant = z.infer; const EnvVarsSchema = z.object({ SUPPLIER_CONFIG_TABLE_NAME: z.string(), + SUPPLIER_QUOTAS_TABLE_NAME: z.string(), PINO_LOG_LEVEL: z.coerce.string().optional(), VARIANT_MAP: z.string().transform((str, _) => { const parsed = JSON.parse(str); diff --git a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts index 0b7f452f1..60ff9d51d 100644 --- a/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts +++ b/lambdas/supplier-allocator/src/handler/__tests__/allocate-handler.test.ts @@ -7,9 +7,13 @@ import { $LetterStatusChangeEvent, LetterStatusChangeEvent, } from "@nhsdigital/nhs-notify-event-schemas-supplier-api/src/events/letter-events"; -import { SupplierConfigRepository } from "@internal/datastore"; +import { + SupplierConfigRepository, + SupplierQuotasRepository, +} from "@internal/datastore"; import createSupplierAllocatorHandler from "../allocate-handler"; import * as supplierConfig from "../../services/supplier-config"; +import * as supplierQuotas from "../../services/supplier-quotas"; import { Deps } from "../../config/deps"; import { EnvVars } from "../../config/env"; @@ -21,6 +25,7 @@ const renderingSchemaVersion: string = ]; jest.mock("../../services/supplier-config"); +jest.mock("../../services/supplier-quotas"); function createSQSEvent(records: SQSRecord[]): SQSEvent { return { @@ -169,12 +174,19 @@ function setupDefaultMocks() { colour: false, duplex: false, }); + ( + supplierQuotas.calculateSupplierAllocatedFactor as jest.Mock + ).mockResolvedValue({ + supplierId: "supplier-1", + factor: 0.5, + }); } describe("createSupplierAllocatorHandler", () => { let mockSqsClient: jest.Mocked; let mockedDeps: jest.Mocked; let mockedSupplierConfigRepo: jest.Mocked; + let mockedSupplierQuotasRepo: jest.Mocked; beforeEach(() => { mockSqsClient = { send: jest.fn(), @@ -191,10 +203,22 @@ describe("createSupplierAllocatorHandler", () => { getPackSpecification: jest.fn(), } as jest.Mocked; + mockedSupplierQuotasRepo = { + ddbClient: {} as any, + config: {} as any, + getOverallAllocation: jest.fn(), + putOverallAllocation: jest.fn(), + updateOverallAllocation: jest.fn(), + getDailyAllocation: jest.fn(), + putDailyAllocation: jest.fn(), + updateDailyAllocation: jest.fn(), + } as jest.Mocked; + mockedDeps = { logger: { error: jest.fn(), info: jest.fn() } as unknown as pino.Logger, env: { SUPPLIER_CONFIG_TABLE_NAME: "SupplierConfigTable", + SUPPLIER_QUOTAS_TABLE_NAME: "SupplierQuotasTable", VARIANT_MAP: { lv1: { supplierId: "supplier1", @@ -206,6 +230,7 @@ describe("createSupplierAllocatorHandler", () => { } as EnvVars, sqsClient: mockSqsClient, supplierConfigRepo: mockedSupplierConfigRepo, + supplierQuotasRepo: mockedSupplierQuotasRepo, } as jest.Mocked; jest.clearAllMocks(); }); @@ -434,6 +459,39 @@ describe("createSupplierAllocatorHandler", () => { ); }); + test("returns batch failure when variant mapping is missing for multiple events", async () => { + const preparedEvent1 = createPreparedV2Event(); + preparedEvent1.data.letterVariantId = "missing-variant1"; + const preparedEvent2 = createPreparedV2Event(); + preparedEvent2.data.letterVariantId = "missing-variant2"; + + const evt: SQSEvent = createSQSEvent([ + createSqsRecord("msg1", JSON.stringify(preparedEvent1)), + createSqsRecord("msg2", JSON.stringify(preparedEvent2)), + ]); + + process.env.UPSERT_LETTERS_QUEUE_URL = "https://sqs.test.queue"; + + // Override variant map to be empty for this test + mockedDeps.env.VARIANT_MAP = {} as any; + + const handler = createSupplierAllocatorHandler(mockedDeps); + const result = await handler(evt, {} as any, {} as any); + if (!result) throw new Error("expected BatchResponse, got void"); + + expect(result.batchItemFailures).toHaveLength(2); + expect(result.batchItemFailures[0].itemIdentifier).toBe("msg1"); + expect(result.batchItemFailures[1].itemIdentifier).toBe("msg2"); + expect( + (mockedDeps.logger.error as jest.Mock).mock.calls.length, + ).toBeGreaterThan(0); + expect((mockedDeps.logger.error as jest.Mock).mock.calls[0][0]).toEqual( + expect.objectContaining({ + description: "No supplier mapping found for variant", + }), + ); + }); + test("handles SQS send errors and returns batch failure", async () => { const preparedEvent = createPreparedV2Event(); diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 7475b9565..eb4c6359e 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -23,6 +23,7 @@ import { getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; +import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; import { Deps } from "../config/deps"; type SupplierSpec = { @@ -116,6 +117,28 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { deps, ); + let supplierAllocationsForPack: SupplierAllocation[] = []; + let supplierFactors: { supplierId: string; factor: number }[] = []; + + if (suppliersForPack && suppliersForPack.length > 0) { + supplierAllocationsForPack = supplierAllocations.filter((alloc) => + suppliersForPack.some((supplier) => supplier.id === alloc.supplier), + ); + + console.log("Supplier allocations for pack", { + supplierAllocationsForPack, + }); + + supplierFactors = await calculateSupplierAllocatedFactor( + supplierAllocationsForPack, + deps, + ); + + console.log("Supplier factors calculated for allocation", { + supplierFactors, + }); + } + deps.logger.info({ description: "Fetched supplier details for supplier allocations", variantId: letterEvent.data.letterVariantId, @@ -127,6 +150,8 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { preferredSupplierPacks, preferredPack, suppliersForPack, + supplierAllocationsForPack, + supplierFactors, }); return allocatedSuppliers; @@ -184,6 +209,8 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { const perAllocationSuccess: AllocationMetrics = new Map(); const perAllocationFailure: AllocationMetrics = new Map(); + // Initialise the supplier quotas. + const tasks = event.Records.map(async (record) => { let supplier = "unknown"; let priority = "unknown"; diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts new file mode 100644 index 000000000..462cbac97 --- /dev/null +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -0,0 +1,49 @@ +import { OverallAllocation } from "@internal/datastore"; +import { SupplierAllocation } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; +import { Deps } from "../config/deps"; + +export async function calculateSupplierAllocatedFactor( + supplierAllocations: SupplierAllocation[], + deps: Deps, +): Promise<{ supplierId: string; factor: number }[]> { + const volumeGroupId = supplierAllocations[0].volumeGroup; // Assuming all allocations are for the same volume group + const overallAllocation: OverallAllocation = + await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); + + const { allocations } = overallAllocation; + + const totalAllocation = Object.values(allocations).reduce( + (sum, allocation) => sum + allocation, + 0, + ); + + return supplierAllocations.map((allocation) => { + const supplierAllocation = allocations[allocation.supplier] ?? 0; + const percentage = + totalAllocation > 0 ? (supplierAllocation / totalAllocation) * 100 : 0; + const factor = percentage / allocation.allocationPercentage; + return { supplierId: allocation.supplier, factor }; + }); +} + +export async function updateSupplierQuota( + groupId: string, + supplierId: string, + newAllocation: number, + deps: Deps, +): Promise { + const overallAllocation = + await deps.supplierQuotasRepo.getOverallAllocation(groupId); + + const updatedAllocations = { + ...overallAllocation.allocations, + [supplierId]: newAllocation, + }; + + const updatedOverallAllocation: OverallAllocation = { + ...overallAllocation, + allocations: updatedAllocations, + }; + + await deps.supplierQuotasRepo.putOverallAllocation(updatedOverallAllocation); +} From 9c39cda3a845807a0ad7a8e5b9514f596a5196f1 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 10:33:15 +0100 Subject: [PATCH 14/19] test supplier config --- config/suppliers/supplier-pack/supplier1-notify-c5.json | 7 +++++++ config/suppliers/supplier-pack/supplier2-notify-c5.json | 7 +++++++ 2 files changed, 14 insertions(+) create mode 100644 config/suppliers/supplier-pack/supplier1-notify-c5.json create mode 100644 config/suppliers/supplier-pack/supplier2-notify-c5.json diff --git a/config/suppliers/supplier-pack/supplier1-notify-c5.json b/config/suppliers/supplier-pack/supplier1-notify-c5.json new file mode 100644 index 000000000..1b49bce1d --- /dev/null +++ b/config/suppliers/supplier-pack/supplier1-notify-c5.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier1-notify-c5", + "packSpecificationId": "notify-c5", + "status": "PROD", + "supplierId": "supplier1" +} diff --git a/config/suppliers/supplier-pack/supplier2-notify-c5.json b/config/suppliers/supplier-pack/supplier2-notify-c5.json new file mode 100644 index 000000000..b012f02f0 --- /dev/null +++ b/config/suppliers/supplier-pack/supplier2-notify-c5.json @@ -0,0 +1,7 @@ +{ + "approval": "APPROVED", + "id": "supplier2-notify-c5", + "packSpecificationId": "notify-c5", + "status": "PROD", + "supplierId": "supplier2" +} From 49afa0585cd90709682102c16ff3baa7547770fc Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 11:00:08 +0100 Subject: [PATCH 15/19] handle non existent overall allocations --- .../src/supplier-quotas-repository.ts | 13 ++++--- .../src/services/supplier-quotas.ts | 35 ++++++++++++++----- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index 0231dea2c..0f6fe9ffc 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -33,7 +33,9 @@ export class SupplierQuotasRepository { readonly config: SupplierQuotasRepositoryConfig, ) {} - async getOverallAllocation(groupId: string): Promise { + async getOverallAllocation( + groupId: string, + ): Promise { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierQuotasTableName, @@ -41,9 +43,7 @@ export class SupplierQuotasRepository { }), ); if (!result.Item) { - throw new Error( - `No overall allocation found for volume group id ${groupId}`, - ); + return undefined; } return $OverallAllocation.parse(result.Item); } @@ -69,7 +69,10 @@ export class SupplierQuotasRepository { newAllocation: number, ): Promise { const overallAllocation = await this.getOverallAllocation(groupId); - const currentAllocation = overallAllocation.allocations[supplierId] ?? 0; + const allocations = overallAllocation?.allocations ?? {}; + const currentAllocation = Object.hasOwn(allocations, supplierId) + ? allocations[supplierId] + : 0; const updatedAllocation = currentAllocation + newAllocation; await this.ddbClient.send( diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index 462cbac97..e966095ef 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -7,9 +7,16 @@ export async function calculateSupplierAllocatedFactor( deps: Deps, ): Promise<{ supplierId: string; factor: number }[]> { const volumeGroupId = supplierAllocations[0].volumeGroup; // Assuming all allocations are for the same volume group - const overallAllocation: OverallAllocation = + const overallAllocation = await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); + if (!overallAllocation) { + return supplierAllocations.map((allocation) => ({ + supplierId: allocation.supplier, + factor: 0, + })); + } + const { allocations } = overallAllocation; const totalAllocation = Object.values(allocations).reduce( @@ -35,15 +42,25 @@ export async function updateSupplierQuota( const overallAllocation = await deps.supplierQuotasRepo.getOverallAllocation(groupId); - const updatedAllocations = { - ...overallAllocation.allocations, - [supplierId]: newAllocation, - }; + const updatedAllocations = overallAllocation + ? { + ...overallAllocation.allocations, + [supplierId]: newAllocation, + } + : { + [supplierId]: newAllocation, + }; - const updatedOverallAllocation: OverallAllocation = { - ...overallAllocation, - allocations: updatedAllocations, - }; + const updatedOverallAllocation: OverallAllocation = overallAllocation + ? { + ...overallAllocation, + allocations: updatedAllocations, + } + : { + id: groupId, + volumeGroup: groupId, + allocations: updatedAllocations, + }; await deps.supplierQuotasRepo.putOverallAllocation(updatedOverallAllocation); } From 7ee04fab3e627b788eaa9736e9d7d3c64d93f8c2 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 14:25:03 +0100 Subject: [PATCH 16/19] store current allocations --- .../src/supplier-quotas-repository.ts | 9 +- .../src/handler/allocate-handler.ts | 86 +++++++++++++++++-- .../src/services/supplier-quotas.ts | 68 +++++++++------ 3 files changed, 127 insertions(+), 36 deletions(-) diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index 0f6fe9ffc..e1212e924 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -95,7 +95,7 @@ export class SupplierQuotasRepository { async getDailyAllocation( groupId: string, date: string, - ): Promise { + ): Promise { const result = await this.ddbClient.send( new GetCommand({ TableName: this.config.supplierQuotasTableName, @@ -106,9 +106,7 @@ export class SupplierQuotasRepository { }), ); if (!result.Item) { - throw new Error( - `No daily allocation found for volume group id ${groupId} and date ${date}`, - ); + return undefined; } return $DailyAllocation.parse(result.Item); } @@ -133,7 +131,8 @@ export class SupplierQuotasRepository { newAllocation: number, ): Promise { const dailyAllocation = await this.getDailyAllocation(groupId, date); - const currentAllocation = dailyAllocation.allocations[supplierId] ?? 0; + const allocations = dailyAllocation?.allocations ?? {}; + const currentAllocation = allocations[supplierId] ?? 0; const updatedAllocation = currentAllocation + newAllocation; await this.ddbClient.send( diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index eb4c6359e..22d6fbc84 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -23,7 +23,10 @@ import { getVariantDetails, getVolumeGroupDetails, } from "../services/supplier-config"; -import { calculateSupplierAllocatedFactor } from "../services/supplier-quotas"; +import { + calculateSupplierAllocatedFactor, + updateSupplierAllocation, +} from "../services/supplier-quotas"; import { Deps } from "../config/deps"; type SupplierSpec = { @@ -32,6 +35,12 @@ type SupplierSpec = { priority: number; billingId: string; }; + +type SupplierDetails = { + supplierSpec: SupplierSpec; + volumeGroupId: string; +}; + type PreparedEvents = LetterRequestPreparedEventV2 | LetterRequestPreparedEvent; // small envelope that must exist in all inputs @@ -71,7 +80,10 @@ function validateType(event: unknown) { } } -async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { +async function getSupplierFromConfig( + letterEvent: PreparedEvents, + deps: Deps, +): Promise { try { const variantDetails: LetterVariant = await getVariantDetails( letterEvent.data.letterVariantId, @@ -119,7 +131,7 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { let supplierAllocationsForPack: SupplierAllocation[] = []; let supplierFactors: { supplierId: string; factor: number }[] = []; - + let selectedSupplierId = "unknown"; // Default to first supplier if no allocations or factors can be calculated if (suppliersForPack && suppliersForPack.length > 0) { supplierAllocationsForPack = supplierAllocations.filter((alloc) => suppliersForPack.some((supplier) => supplier.id === alloc.supplier), @@ -137,6 +149,16 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { console.log("Supplier factors calculated for allocation", { supplierFactors, }); + + // Get the supplierid with the lowest factor + selectedSupplierId = supplierFactors[0].supplierId; + let lowestFactor = supplierFactors[0].factor; + for (const supplierFactor of supplierFactors) { + if (supplierFactor.factor < lowestFactor) { + lowestFactor = supplierFactor.factor; + selectedSupplierId = supplierFactor.supplierId; + } + } } deps.logger.info({ @@ -152,16 +174,26 @@ async function getSupplierFromConfig(letterEvent: PreparedEvents, deps: Deps) { suppliersForPack, supplierAllocationsForPack, supplierFactors, + selectedSupplierId, }); - return allocatedSuppliers; + const supplierDetails: SupplierDetails = { + supplierSpec: { + supplierId: selectedSupplierId, + specId: preferredPack.id, + priority: 0, + billingId: preferredPack.billingId, + }, + volumeGroupId: volumeGroupDetails.id, + }; + return supplierDetails; } catch (error) { deps.logger.error({ description: "Error fetching supplier from config", err: error, variantId: letterEvent.data.letterVariantId, }); - return []; + return undefined; } } @@ -170,6 +202,7 @@ function getSupplier(letterEvent: PreparedEvents, deps: Deps): SupplierSpec { } type AllocationMetrics = Map>; +type VolumeGroupAllocation = Map>; function incrementMetric( map: AllocationMetrics, @@ -203,12 +236,40 @@ function emitMetrics( } } +function incrementAllocation( + map: VolumeGroupAllocation, + volumeGroupId: string, + supplierId: string, + allocation: number, +) { + const groupAllocations = map.get(volumeGroupId) ?? {}; + groupAllocations[supplierId] = + (groupAllocations[supplierId] ?? 0) + allocation; + map.set(volumeGroupId, groupAllocations); +} + +async function saveAllocations( + deps: Deps, + volumeGroupAllocations: VolumeGroupAllocation, +) { + for (const [volumeGroupId, allocations] of volumeGroupAllocations) { + for (const [supplierId, allocation] of Object.entries(allocations)) { + await updateSupplierAllocation( + volumeGroupId, + supplierId, + allocation, + deps, + ); + } + } +} + export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { return async (event: SQSEvent) => { const batchItemFailures: SQSBatchItemFailure[] = []; const perAllocationSuccess: AllocationMetrics = new Map(); const perAllocationFailure: AllocationMetrics = new Map(); - + const volumeGroupAllocations: VolumeGroupAllocation = new Map(); // Map of volume group id to supplier allocations for that group, used to track the allocations calculated in this batch for emitting metrics and updating the quotas after processing the batch // Initialise the supplier quotas. const tasks = event.Records.map(async (record) => { @@ -225,7 +286,17 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { validateType(letterEvent); const supplierSpec = getSupplier(letterEvent as PreparedEvents, deps); - await getSupplierFromConfig(letterEvent as PreparedEvents, deps); + const supplierDetails = await getSupplierFromConfig( + letterEvent as PreparedEvents, + deps, + ); + + incrementAllocation( + volumeGroupAllocations, + supplierDetails?.volumeGroupId ?? "unknown", + supplierSpec.supplierId, + 1, + ); supplier = supplierSpec.supplierId; priority = String(supplierSpec.priority); @@ -276,6 +347,7 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { emitMetrics(perAllocationSuccess, MetricStatus.Success, deps); emitMetrics(perAllocationFailure, MetricStatus.Failure, deps); + await saveAllocations(deps, volumeGroupAllocations); return { batchItemFailures }; }; } diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index e966095ef..3c42c51b6 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -1,4 +1,4 @@ -import { OverallAllocation } from "@internal/datastore"; +import { DailyAllocation, OverallAllocation } from "@internal/datastore"; import { SupplierAllocation } from "@nhsdigital/nhs-notify-event-schemas-supplier-config"; import { Deps } from "../config/deps"; @@ -33,34 +33,54 @@ export async function calculateSupplierAllocatedFactor( }); } -export async function updateSupplierQuota( - groupId: string, +// function to either update or create a new overall allocation and daily allocation for a given supplier, volume group and allocation amount +// if the overall allocation for the volume group does not exist, it will be created with the new allocation for the supplier and 0 for the other suppliers + +export async function updateSupplierAllocation( + volumeGroupId: string, supplierId: string, newAllocation: number, deps: Deps, ): Promise { const overallAllocation = - await deps.supplierQuotasRepo.getOverallAllocation(groupId); - - const updatedAllocations = overallAllocation - ? { - ...overallAllocation.allocations, + await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); + if (overallAllocation) { + await deps.supplierQuotasRepo.updateOverallAllocation( + volumeGroupId, + supplierId, + newAllocation, + ); + } else { + const newOverallAllocation: OverallAllocation = { + id: volumeGroupId, + volumeGroup: volumeGroupId, + allocations: { [supplierId]: newAllocation, - } - : { + }, + }; + await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); + } + const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format + const dailyAllocation = await deps.supplierQuotasRepo.getDailyAllocation( + volumeGroupId, + dailyAllocationDate, + ); + if (dailyAllocation) { + await deps.supplierQuotasRepo.updateDailyAllocation( + volumeGroupId, + dailyAllocationDate, + supplierId, + newAllocation, + ); + } else { + const newDailyAllocation: DailyAllocation = { + id: `${volumeGroupId}#DATE#${dailyAllocationDate}`, + date: dailyAllocationDate, + volumeGroup: volumeGroupId, + allocations: { [supplierId]: newAllocation, - }; - - const updatedOverallAllocation: OverallAllocation = overallAllocation - ? { - ...overallAllocation, - allocations: updatedAllocations, - } - : { - id: groupId, - volumeGroup: groupId, - allocations: updatedAllocations, - }; - - await deps.supplierQuotasRepo.putOverallAllocation(updatedOverallAllocation); + }, + }; + await deps.supplierQuotasRepo.putDailyAllocation(newDailyAllocation); + } } From 4fbcc9f68b6dbcec6108ea6ff82b4517ab966271 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 15:01:17 +0100 Subject: [PATCH 17/19] debug logging --- .../src/handler/allocate-handler.ts | 24 ++++++++++++------- .../src/services/supplier-config.ts | 3 --- .../src/services/supplier-quotas.ts | 11 +++++++++ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lambdas/supplier-allocator/src/handler/allocate-handler.ts b/lambdas/supplier-allocator/src/handler/allocate-handler.ts index 22d6fbc84..50e787723 100644 --- a/lambdas/supplier-allocator/src/handler/allocate-handler.ts +++ b/lambdas/supplier-allocator/src/handler/allocate-handler.ts @@ -137,19 +137,11 @@ async function getSupplierFromConfig( suppliersForPack.some((supplier) => supplier.id === alloc.supplier), ); - console.log("Supplier allocations for pack", { - supplierAllocationsForPack, - }); - supplierFactors = await calculateSupplierAllocatedFactor( supplierAllocationsForPack, deps, ); - console.log("Supplier factors calculated for allocation", { - supplierFactors, - }); - // Get the supplierid with the lowest factor selectedSupplierId = supplierFactors[0].supplierId; let lowestFactor = supplierFactors[0].factor; @@ -241,17 +233,27 @@ function incrementAllocation( volumeGroupId: string, supplierId: string, allocation: number, + deps: Deps, ) { const groupAllocations = map.get(volumeGroupId) ?? {}; groupAllocations[supplierId] = (groupAllocations[supplierId] ?? 0) + allocation; map.set(volumeGroupId, groupAllocations); + deps.logger.info({ + description: "Updated allocations for volume group and supplier", + volumeGroupId, + groupAllocations, + }); } async function saveAllocations( deps: Deps, volumeGroupAllocations: VolumeGroupAllocation, ) { + deps.logger.info({ + description: "Saving supplier allocations for volume groups", + volumeGroupAllocations, + }); for (const [volumeGroupId, allocations] of volumeGroupAllocations) { for (const [supplierId, allocation] of Object.entries(allocations)) { await updateSupplierAllocation( @@ -291,11 +293,17 @@ export default function createSupplierAllocatorHandler(deps: Deps): SQSHandler { deps, ); + deps.logger.info({ + description: "Resolved supplier details from config", + supplierDetails, + }); + incrementAllocation( volumeGroupAllocations, supplierDetails?.volumeGroupId ?? "unknown", supplierSpec.supplierId, 1, + deps, ); supplier = supplierSpec.supplierId; diff --git a/lambdas/supplier-allocator/src/services/supplier-config.ts b/lambdas/supplier-allocator/src/services/supplier-config.ts index 1eaa1fc48..c86640513 100644 --- a/lambdas/supplier-allocator/src/services/supplier-config.ts +++ b/lambdas/supplier-allocator/src/services/supplier-config.ts @@ -199,9 +199,6 @@ function evaluateContraint( constraintValue: number, operator: string, ): boolean { - console.log( - `Evaluating constraint: actualValue ${actualValue}, constraintValue ${constraintValue}, operator ${operator}`, - ); switch (operator) { case "EQUALS": { return actualValue === constraintValue; diff --git a/lambdas/supplier-allocator/src/services/supplier-quotas.ts b/lambdas/supplier-allocator/src/services/supplier-quotas.ts index 3c42c51b6..393a0c182 100644 --- a/lambdas/supplier-allocator/src/services/supplier-quotas.ts +++ b/lambdas/supplier-allocator/src/services/supplier-quotas.ts @@ -45,6 +45,11 @@ export async function updateSupplierAllocation( const overallAllocation = await deps.supplierQuotasRepo.getOverallAllocation(volumeGroupId); if (overallAllocation) { + deps.logger.info({ + description: "Existing overall allocation found for volume group", + volumeGroupId, + overallAllocation, + }); await deps.supplierQuotasRepo.updateOverallAllocation( volumeGroupId, supplierId, @@ -58,6 +63,12 @@ export async function updateSupplierAllocation( [supplierId]: newAllocation, }, }; + deps.logger.info({ + description: + "No overall allocation found for volume group, creating new one", + volumeGroupId, + newOverallAllocation, + }); await deps.supplierQuotasRepo.putOverallAllocation(newOverallAllocation); } const dailyAllocationDate = new Date().toISOString().split("T")[0]; // Get current date in YYYY-MM-DD format From 97ccad0d903584933bd3c4ef9a621c0eb6dfe44e Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 15:39:40 +0100 Subject: [PATCH 18/19] error checking --- .../src/supplier-quotas-repository.ts | 60 ++++++++++++------- 1 file changed, 40 insertions(+), 20 deletions(-) diff --git a/internal/datastore/src/supplier-quotas-repository.ts b/internal/datastore/src/supplier-quotas-repository.ts index e1212e924..48b6faefb 100644 --- a/internal/datastore/src/supplier-quotas-repository.ts +++ b/internal/datastore/src/supplier-quotas-repository.ts @@ -49,16 +49,26 @@ export class SupplierQuotasRepository { } async putOverallAllocation(allocation: OverallAllocation): Promise { - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "overall-allocation", - allocation.id, - $OverallAllocation.parse(allocation), - ), - }), - ); + try { + const parsedAllocation = $OverallAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "overall-allocation", + allocation.id, + parsedAllocation, + ), + }), + ); + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to put overall allocation for id ${allocation.id}: ${error.message}`, + ); + } + throw error; + } } // Update the overallAllocation table updating the allocations array for a given volume group @@ -112,16 +122,26 @@ export class SupplierQuotasRepository { } async putDailyAllocation(allocation: DailyAllocation): Promise { - await this.ddbClient.send( - new PutCommand({ - TableName: this.config.supplierQuotasTableName, - Item: ItemForRecord( - "daily-allocation", - `${allocation.volumeGroup}#DATE#${allocation.date}`, - $DailyAllocation.parse(allocation), - ), - }), - ); + try { + const parsedAllocation = $DailyAllocation.parse(allocation); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.supplierQuotasTableName, + Item: ItemForRecord( + "daily-allocation", + `${allocation.volumeGroup}#DATE#${allocation.date}`, + parsedAllocation, + ), + }), + ); + } catch (error) { + if (error instanceof Error) { + throw new Error( + `Failed to put daily allocation for volume group ${allocation.volumeGroup} and date ${allocation.date}: ${error.message}`, + ); + } + throw error; + } } async updateDailyAllocation( From ca42122bee8c1103b00715f6d4471e2eeb875808 Mon Sep 17 00:00:00 2001 From: David Wass Date: Fri, 17 Apr 2026 16:13:31 +0100 Subject: [PATCH 19/19] lambda permissions --- .../components/api/module_lambda_supplier_allocator.tf | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf index ee9924a9d..fc0882302 100644 --- a/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf +++ b/infrastructure/terraform/components/api/module_lambda_supplier_allocator.tf @@ -89,7 +89,9 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" { actions = [ "dynamodb:GetItem", - "dynamodb:Query" + "dynamodb:Query", + "dynamodb:PutItem", + "dynamodb:UpdateItem" ] resources = [