From dcb398da1ed5ae407fe8b03dafe349607471ebcc Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Thu, 15 Jan 2026 10:02:04 +0000 Subject: [PATCH 01/63] Update hint text on UI for uploading vaccs records Current hint text is potentially misleading - users might think Mavis automatically reports uploaded records to GPs and/or NHS England, which is not the case. We replace it with something clearer. MAV-3047 --- app/views/draft_imports/type.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/draft_imports/type.html.erb b/app/views/draft_imports/type.html.erb index 104d735d21..4f2f623062 100644 --- a/app/views/draft_imports/type.html.erb +++ b/app/views/draft_imports/type.html.erb @@ -20,7 +20,7 @@ <%= f.govuk_radio_button :type, :immunisation, label: { text: "Vaccination records" }, - hint: { text: "Records of previous vaccinations to be reported to GPs and/or NHS England" } %> + hint: { text: "Relevant vaccination history for children in the cohort" } %> <% end %> <%= f.govuk_submit %> From 4cd97325d4f3b8b45edda6bd1cbf79a48f1a224f Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 7 Jan 2026 17:17:11 +0000 Subject: [PATCH 02/63] Allow bulk upload users to edit vaccinator Jira-Issue: MAV-2913 --- ...pp_vaccination_record_summary_component.rb | 7 ++++++ .../draft_vaccination_records_controller.rb | 3 ++- app/models/draft_vaccination_record.rb | 11 +++++++++ .../confirm.html.erb | 1 + .../vaccinator.html.erb | 19 +++++++++++++++ config/locales/en.yml | 8 +++++++ config/locales/wicked.en.yml | 1 + spec/features/edit_vaccination_record_spec.rb | 24 ++++++++++++++++++- 8 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 app/views/draft_vaccination_records/vaccinator.html.erb diff --git a/app/components/app_vaccination_record_summary_component.rb b/app/components/app_vaccination_record_summary_component.rb index 4738f6c08a..132e2dd7a0 100644 --- a/app/components/app_vaccination_record_summary_component.rb +++ b/app/components/app_vaccination_record_summary_component.rb @@ -218,6 +218,13 @@ def call summary_list.with_row do |row| row.with_key { "Vaccinator" } row.with_value { vaccinator_value } + if (href = @change_links[:vaccinator]) + row.with_action( + text: "Change", + visually_hidden_text: "vaccinator", + href: + ) + end end end diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index f104a9f67d..41cb0c9868 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -169,7 +169,8 @@ def update_params location: %i[location_id], notes: %i[notes], outcome: %i[outcome], - supplier: %i[supplied_by_user_id] + supplier: %i[supplied_by_user_id], + vaccinator: %i[performed_by_given_name performed_by_family_name] }.fetch(current_step) params diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index f79299bd6c..9c0c589bbf 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -59,6 +59,7 @@ def wizard_steps (:dose if administered? && can_be_half_dose?), (:batch if administered?), (:location if session&.generic_clinic?), + (:vaccinator if bulk_upload_user_and_record?), :confirm ].compact end @@ -110,6 +111,11 @@ def wizard_steps validates :notes, length: { maximum: 1000 } end + on_wizard_step :vaccinator, exact: true do + validates :performed_by_given_name, presence: true + validates :performed_by_family_name, presence: true + end + with_options on: :update, if: -> do required_for_step?(:confirm, exact: true) && administered? @@ -390,6 +396,11 @@ def can_change_outcome? outcome != "already_had" || editing? || session.nil? || session.today? end + def bulk_upload_user_and_record? + @current_user.selected_team.has_upload_only_access? && + sourced_from_bulk_upload? + end + def requires_supplied_by? performed_by_user && !performed_by_user&.show_in_suppliers end diff --git a/app/views/draft_vaccination_records/confirm.html.erb b/app/views/draft_vaccination_records/confirm.html.erb index fdca31d3b8..8669f17918 100644 --- a/app/views/draft_vaccination_records/confirm.html.erb +++ b/app/views/draft_vaccination_records/confirm.html.erb @@ -25,6 +25,7 @@ notes: wizard_path("notes"), outcome: @draft_vaccination_record.wizard_steps.include?(:outcome) ? wizard_path("outcome") : nil, performed_at: wizard_path("date-and-time"), + vaccinator: wizard_path("vaccinator"), } %> <% show_notes = @draft_vaccination_record.editing? %> diff --git a/app/views/draft_vaccination_records/vaccinator.html.erb b/app/views/draft_vaccination_records/vaccinator.html.erb new file mode 100644 index 0000000000..47c7d629d7 --- /dev/null +++ b/app/views/draft_vaccination_records/vaccinator.html.erb @@ -0,0 +1,19 @@ +<% content_for :before_main do %> + <%= govuk_back_link(href: @back_link_path) %> +<% end %> + +<% content_for :page_title, "Vaccinator" %> + +<%= form_with model: @draft_vaccination_record, url: wizard_path, method: :put do |f| %> + <%= f.govuk_error_summary %> + + <%= @patient.full_name %> + <%= h1 "Vaccinator" %> + + <%= f.govuk_text_field :performed_by_given_name, label: { text: "First name" }, + hint: { text: "Or given name" } %> + <%= f.govuk_text_field :performed_by_family_name, label: { text: "Last name" }, + hint: { text: "Or family name" } %> + + <%= f.govuk_submit "Continue" %> +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index c9d1638ac3..2895ed65af 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -142,6 +142,10 @@ en: missing_month: Enter a month missing_year: Enter a year invalid: Enter a valid date and time + performed_by_given_name: + blank: First name can't be blank + performed_by_family_name: + blank: Last name can't be blank health_answer: attributes: notes: @@ -558,6 +562,10 @@ en: attributes: performed_at: less_than_or_equal_to: Enter a time in the past + performed_by_given_name: + blank: First name can't be blank + performed_by_family_name: + blank: Last name can't be blank consent_forms: index: title: Unmatched consent responses diff --git a/config/locales/wicked.en.yml b/config/locales/wicked.en.yml index e1ebbbf4f6..f766ad9385 100644 --- a/config/locales/wicked.en.yml +++ b/config/locales/wicked.en.yml @@ -51,6 +51,7 @@ en: triage: triage type: type vaccine: vaccine + vaccinator: vaccinator when: when who: who without_gelatine: without-gelatine diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index d1df309853..64689cdef2 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -292,6 +292,11 @@ and_i_click_on_edit_vaccination_record then_i_see_the_edit_vaccination_record_page + when_i_edit_the_vaccinator + and_i_enter_a_new_first_name_and_last_name + then_i_see_the_edit_vaccination_record_page + and_i_should_see_the_updated_vaccinator_details + when_i_click_on_save_changes then_i_should_see_the_vaccination_record end @@ -415,7 +420,10 @@ def and_a_bulk_uploaded_vaccination_record_exists uploaded_by: @team.users.first, batch: @batch, patient: @patient, - programme: @programme + programme: @programme, + performed_by_user: nil, + performed_by_given_name: "Albus", + performed_by_family_name: "Dumbledore" ) end @@ -523,6 +531,20 @@ def when_i_click_back click_on "Back" end + def when_i_edit_the_vaccinator + click_on "Change vaccinator" + end + + def and_i_enter_a_new_first_name_and_last_name + fill_in "First name", with: "New" + fill_in "Last name", with: "Name" + click_on "Continue" + end + + def and_i_should_see_the_updated_vaccinator_details + expect(page).to have_content("VaccinatorNAME, New") + end + def when_i_click_on_change_date click_on "Change date" end From 304308f3326e43d9bdee3c73d671da38ee3075d4 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 7 Jan 2026 17:36:40 +0000 Subject: [PATCH 03/63] Allow bulk upload users to edit dose sequence This allows the user to choose from radio buttons between 1 and `Programme.maximum_dose_sequence` Jira-Issue: MAV-2916 --- ...pp_vaccination_record_summary_component.rb | 7 +++++++ .../draft_vaccination_records_controller.rb | 1 + app/models/draft_vaccination_record.rb | 9 +++++++++ .../confirm.html.erb | 1 + .../dose_sequence.html.erb | 19 +++++++++++++++++++ config/locales/wicked.en.yml | 1 + spec/features/edit_vaccination_record_spec.rb | 18 ++++++++++++++++++ 7 files changed, 56 insertions(+) create mode 100644 app/views/draft_vaccination_records/dose_sequence.html.erb diff --git a/app/components/app_vaccination_record_summary_component.rb b/app/components/app_vaccination_record_summary_component.rb index 132e2dd7a0..33d4183d9a 100644 --- a/app/components/app_vaccination_record_summary_component.rb +++ b/app/components/app_vaccination_record_summary_component.rb @@ -154,6 +154,13 @@ def call summary_list.with_row do |row| row.with_key { "Dose number" } row.with_value { dose_number_value } + if (href = @change_links[:dose_sequence]) + row.with_action( + text: "Change", + href:, + visually_hidden_text: "dose number" + ) + end end end end diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index 41cb0c9868..3311975d92 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -161,6 +161,7 @@ def update_params date_and_time: %i[performed_at], delivery: %i[delivery_site delivery_method], dose: %i[full_dose], + dose_sequence: %i[dose_sequence], identity: %i[ identity_check_confirmed_by_patient identity_check_confirmed_by_other_name diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 9c0c589bbf..11785c14f9 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -59,6 +59,7 @@ def wizard_steps (:dose if administered? && can_be_half_dose?), (:batch if administered?), (:location if session&.generic_clinic?), + (:dose_sequence if bulk_upload_user_and_record?), (:vaccinator if bulk_upload_user_and_record?), :confirm ].compact @@ -116,6 +117,14 @@ def wizard_steps validates :performed_by_family_name, presence: true end + on_wizard_step :dose_sequence, exact: true do + validates :dose_sequence, + presence: true, + inclusion: { + in: ->(record) { 1..record.programme.maximum_dose_sequence } + } + end + with_options on: :update, if: -> do required_for_step?(:confirm, exact: true) && administered? diff --git a/app/views/draft_vaccination_records/confirm.html.erb b/app/views/draft_vaccination_records/confirm.html.erb index 8669f17918..6eb8b0dd6f 100644 --- a/app/views/draft_vaccination_records/confirm.html.erb +++ b/app/views/draft_vaccination_records/confirm.html.erb @@ -18,6 +18,7 @@ batch: wizard_path("batch"), delivery_method: wizard_path("delivery"), delivery_site: wizard_path("delivery"), + dose_sequence: wizard_path("dose-sequence"), dose_volume: @draft_vaccination_record.wizard_steps.include?(:dose) ? wizard_path("dose") : nil, identity: wizard_path("identity"), supplier: wizard_path("supplier"), diff --git a/app/views/draft_vaccination_records/dose_sequence.html.erb b/app/views/draft_vaccination_records/dose_sequence.html.erb new file mode 100644 index 0000000000..60be1d6a7b --- /dev/null +++ b/app/views/draft_vaccination_records/dose_sequence.html.erb @@ -0,0 +1,19 @@ +<% content_for :before_main do %> + <%= govuk_back_link(href: @back_link_path) %> +<% end %> + +<% content_for :page_title, "Dose number" %> + +<%= form_with model: @draft_vaccination_record, url: wizard_path, method: :put do |f| %> + <%= f.govuk_error_summary %> + + <%= f.govuk_radio_buttons_fieldset :dose_sequence, + caption: { text: @patient.full_name, size: "l" }, + legend: { size: "l", tag: "h1", text: "Dose number" } do %> + <% (1..@draft_vaccination_record.programme.maximum_dose_sequence).each do |dose_sequence| %> + <%= f.govuk_radio_button :dose_sequence, dose_sequence, label: { text: dose_sequence.ordinalize } %> + <% end %> + <% end %> + + <%= f.govuk_submit "Continue" %> +<% end %> diff --git a/config/locales/wicked.en.yml b/config/locales/wicked.en.yml index f766ad9385..39fcb33148 100644 --- a/config/locales/wicked.en.yml +++ b/config/locales/wicked.en.yml @@ -17,6 +17,7 @@ en: delegation: delegation delivery: delivery dose: dose + dose_sequence: dose-sequence education_setting: education-setting file_format: file-format gp: gp diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index 64689cdef2..279e921192 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -297,6 +297,11 @@ then_i_see_the_edit_vaccination_record_page and_i_should_see_the_updated_vaccinator_details + when_i_click_on_change_dose_number + and_i_choose_the_second_dose + then_i_see_the_edit_vaccination_record_page + and_i_should_see_the_updated_dose_number + when_i_click_on_save_changes then_i_should_see_the_vaccination_record end @@ -545,6 +550,19 @@ def and_i_should_see_the_updated_vaccinator_details expect(page).to have_content("VaccinatorNAME, New") end + def when_i_click_on_change_dose_number + click_on "Change dose number" + end + + def and_i_choose_the_second_dose + choose "2nd" + click_on "Continue" + end + + def and_i_should_see_the_updated_dose_number + expect(page).to have_content("Dose number2nd") + end + def when_i_click_on_change_date click_on "Change date" end From 52909198eb1d59a1a7db32b55433c8e261b09152 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 14 Jan 2026 12:58:27 +0000 Subject: [PATCH 04/63] Disallow bulk upload users from changing `outcome` On records which were bulk uploaded Jira-Issue: MAV-2905 --- app/models/draft_vaccination_record.rb | 3 ++- spec/features/edit_vaccination_record_spec.rb | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 11785c14f9..d944c7c094 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -402,7 +402,8 @@ def vaccine_method def can_be_half_dose? = vaccine_method == "nasal" def can_change_outcome? - outcome != "already_had" || editing? || session.nil? || session.today? + (outcome != "already_had" || editing? || session.nil? || session.today?) && + !bulk_upload_user_and_record? end def bulk_upload_user_and_record? diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index 279e921192..f025ed19f4 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -286,11 +286,13 @@ when_i_click_on_edit_vaccination_record then_i_see_the_edit_vaccination_record_page + and_i_should_not_see_a_change_outcome_link when_i_click_back then_i_should_see_the_vaccination_record and_i_click_on_edit_vaccination_record then_i_see_the_edit_vaccination_record_page + and_i_should_not_see_a_change_outcome_link when_i_edit_the_vaccinator and_i_enter_a_new_first_name_and_last_name @@ -653,6 +655,10 @@ def when_i_click_on_change_outcome click_on "Change outcome" end + def and_i_should_not_see_a_change_outcome_link + expect(page).not_to have_link("Change outcome") + end + def then_i_should_see_the_change_outcome_form expect(page).to have_content("Vaccination outcome") end From a491ad7a67c346a235d6c50001cdad1ff8fcf338 Mon Sep 17 00:00:00 2001 From: Jake Benilov Date: Fri, 16 Jan 2026 10:56:22 +0000 Subject: [PATCH 05/63] Fix SNOMED procedure term for flu vaccinations Before this change, Mavis generates mismatched procedure terms and codes for flu vaccinations. This change: * fixes the underlying mismatch * reduces the likelihood of this happening in future by bringing together the code and term structures into one JIRA: https://nhsd-jira.digital.nhs.uk/browse/MAV-3076 --- app/lib/fhir_mapper/vaccine.rb | 2 +- app/models/vaccination_record.rb | 2 +- app/models/vaccine.rb | 107 ++++++++++++++++++--------- spec/lib/fhir_mapper/vaccine_spec.rb | 52 ++++++++++--- spec/models/vaccine_spec.rb | 42 +++++++++-- 5 files changed, 147 insertions(+), 58 deletions(-) diff --git a/app/lib/fhir_mapper/vaccine.rb b/app/lib/fhir_mapper/vaccine.rb index 7fd73884d4..4c65a0bfa0 100644 --- a/app/lib/fhir_mapper/vaccine.rb +++ b/app/lib/fhir_mapper/vaccine.rb @@ -45,7 +45,7 @@ def fhir_procedure_coding(dose_sequence:) FHIR::Coding.new( system: "http://snomed.info/sct", code: snomed_procedure_code(dose_sequence:), - display: snomed_procedure_term + display: snomed_procedure_term(dose_sequence:) ) ] ) diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index 27674a118f..8f0e52e258 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -273,7 +273,7 @@ def delivery_method_snomed_term def snomed_procedure_code = vaccine&.snomed_procedure_code(dose_sequence:) - delegate :snomed_procedure_term, to: :vaccine, allow_nil: true + def snomed_procedure_term = vaccine&.snomed_procedure_term(dose_sequence:) def notifier = Notifier::VaccinationRecord.new(self) diff --git a/app/models/vaccine.rb b/app/models/vaccine.rb index 492ece484b..3058b48456 100644 --- a/app/models/vaccine.rb +++ b/app/models/vaccine.rb @@ -108,60 +108,93 @@ def self.delivery_method_to_vaccine_method(delivery_method) suitable_delivery_methods.keys.first end - SNOMED_PROCEDURE_CODES = { + SNOMED_PROCEDURE = { "flu" => { - "injection" => %w[985151000000100 985171000000109], - "nasal" => %w[884861000000100 884881000000109] + "injection" => [ + { + code: "985151000000100", + term: + "Administration of first inactivated seasonal influenza vaccination" + }, + { + code: "985171000000109", + term: + "Administration of second inactivated seasonal influenza vaccination" + } + ], + "nasal" => [ + { + code: "884861000000100", + term: + "Administration of first intranasal seasonal influenza vaccination" + }, + { + code: "884881000000109", + term: + "Administration of second intranasal seasonal influenza vaccination" + } + ] }, "hpv" => { - "injection" => "761841000" + "injection" => { + code: "761841000", + term: + "Administration of vaccine product containing only Human " \ + "papillomavirus antigen (procedure)" + } }, "menacwy" => { - "injection" => "871874000" + "injection" => { + code: "871874000", + term: + "Administration of vaccine product containing only Neisseria " \ + "meningitidis serogroup A, C, W135 and Y antigens (procedure)" + } }, "mmr" => { - "injection" => "38598009" + "injection" => { + code: "38598009", + term: + "Administration of vaccine product containing only Measles " \ + "morbillivirus and Mumps orthorubulavirus and Rubella virus " \ + "antigens (procedure)" + } }, "mmrv" => { - "injection" => "432636005" + "injection" => { + code: "432636005", + term: + "Administration of vaccine product containing only Human " \ + "alphaherpesvirus 3 and Measles morbillivirus and Mumps " \ + "orthorubulavirus and Rubella virus antigens (procedure)" + } }, "td_ipv" => { - "injection" => "866186002" + "injection" => { + code: "866186002", + term: + "Administration of vaccine product containing only Clostridium " \ + "tetani and Corynebacterium diphtheriae and Human poliovirus " \ + "antigens (procedure)" + } } }.freeze - def snomed_procedure_code(dose_sequence:) - codes = - SNOMED_PROCEDURE_CODES.fetch( - programme.variant_type || programme.type - ).fetch(method) - codes.is_a?(Array) ? codes[dose_sequence - 1] : codes + def snomed_procedure(dose_sequence: nil) + procedures = + SNOMED_PROCEDURE.fetch(programme.variant_type || programme.type).fetch( + method + ) + + procedures.is_a?(Array) ? procedures[(dose_sequence || 1) - 1] : procedures end - SNOMED_PROCEDURE_TERMS = { - "flu" => "Seasonal influenza vaccination (procedure)", - "hpv" => - "Administration of vaccine product containing only Human " \ - "papillomavirus antigen (procedure)", - "menacwy" => - "Administration of vaccine product containing only Neisseria " \ - "meningitidis serogroup A, C, W135 and Y antigens (procedure)", - "mmr" => - "Administration of vaccine product containing only Measles " \ - "morbillivirus and Mumps orthorubulavirus and Rubella virus " \ - "antigens (procedure)", - "mmrv" => - "Administration of vaccine product containing only Human " \ - "alphaherpesvirus 3 and Measles morbillivirus and Mumps " \ - "orthorubulavirus and Rubella virus antigens (procedure)", - "td_ipv" => - "Administration of vaccine product containing only Clostridium " \ - "tetani and Corynebacterium diphtheriae and Human poliovirus " \ - "antigens (procedure)" - }.freeze + def snomed_procedure_code(dose_sequence:) + snomed_procedure(dose_sequence:).fetch(:code) + end - def snomed_procedure_term - SNOMED_PROCEDURE_TERMS.fetch(programme.variant_type || programme.type) + def snomed_procedure_term(dose_sequence: nil) + snomed_procedure(dose_sequence:).fetch(:term) end private diff --git a/spec/lib/fhir_mapper/vaccine_spec.rb b/spec/lib/fhir_mapper/vaccine_spec.rb index 1a085ff878..87c25d2bad 100644 --- a/spec/lib/fhir_mapper/vaccine_spec.rb +++ b/spec/lib/fhir_mapper/vaccine_spec.rb @@ -36,22 +36,52 @@ end describe "#fhir_procedure_coding" do - subject(:fhir_procedure_coding) do - fhir_mapper.fhir_procedure_coding(dose_sequence: nil) + context "with HPV vaccine" do + subject(:fhir_procedure_coding) do + fhir_mapper.fhir_procedure_coding(dose_sequence: nil) + end + + it { should be_a(FHIR::CodeableConcept) } + + describe "its coding" do + subject { fhir_procedure_coding.coding.first } + + its(:system) { should eq("http://snomed.info/sct") } + its(:code) { should eq("761841000") } + + its(:display) do + should eq( + "Administration of vaccine product containing only Human papillomavirus antigen (procedure)" + ) + end + end end - it { should be_a(FHIR::CodeableConcept) } + context "with flu injection vaccine, dose 1" do + let(:vaccine) { create(:vaccine, :flu, :injection) } - describe "its coding" do - subject { fhir_procedure_coding.coding.first } + it "pairs the correct code with the correct term" do + coding = + fhir_mapper.fhir_procedure_coding(dose_sequence: 1).coding.first - its(:system) { should eq("http://snomed.info/sct") } - its(:code) { should eq("761841000") } + expect(coding.code).to eq("985151000000100") + expect(coding.display).to eq( + "Administration of first inactivated seasonal influenza vaccination" + ) + end + end - its(:display) do - should eq( - "Administration of vaccine product containing only Human papillomavirus antigen (procedure)" - ) + context "with flu nasal vaccine, dose 2" do + let(:vaccine) { create(:vaccine, :flu, :nasal) } + + it "pairs the correct code with the correct term" do + coding = + fhir_mapper.fhir_procedure_coding(dose_sequence: 2).coding.first + + expect(coding.code).to eq("884881000000109") + expect(coding.display).to eq( + "Administration of second intranasal seasonal influenza vaccination" + ) end end end diff --git a/spec/models/vaccine_spec.rb b/spec/models/vaccine_spec.rb index 823c310f0d..500b4fa9f8 100644 --- a/spec/models/vaccine_spec.rb +++ b/spec/models/vaccine_spec.rb @@ -104,25 +104,51 @@ end describe "#snomed_procedure_term" do - subject(:snomed_procedure_term) { vaccine.snomed_procedure_term } - - context "with an injection flu vaccine" do + context "with flu injection vaccine" do let(:vaccine) { build(:vaccine, :flu, :injection) } - it { should eq("Seasonal influenza vaccination (procedure)") } + context "with dose sequence 1" do + it "returns the term for first dose injection" do + expect(vaccine.snomed_procedure_term(dose_sequence: 1)).to eq( + "Administration of first inactivated seasonal influenza vaccination" + ) + end + end + + context "with dose sequence 2" do + it "returns the term for second dose injection" do + expect(vaccine.snomed_procedure_term(dose_sequence: 2)).to eq( + "Administration of second inactivated seasonal influenza vaccination" + ) + end + end end - context "with a nasal flu vaccine" do + context "with flu nasal vaccine" do let(:vaccine) { build(:vaccine, :flu, :nasal) } - it { should eq("Seasonal influenza vaccination (procedure)") } + context "with dose sequence 1" do + it "returns the term for first dose nasal" do + expect(vaccine.snomed_procedure_term(dose_sequence: 1)).to eq( + "Administration of first intranasal seasonal influenza vaccination" + ) + end + end + + context "with dose sequence 2" do + it "returns the term for second dose nasal" do + expect(vaccine.snomed_procedure_term(dose_sequence: 2)).to eq( + "Administration of second intranasal seasonal influenza vaccination" + ) + end + end end context "with an MMR vaccine" do let(:vaccine) { build(:vaccine, :mmr) } it do - expect(snomed_procedure_term).to eq( + expect(vaccine.snomed_procedure_term).to eq( "Administration of vaccine product containing only Measles " \ "morbillivirus and Mumps orthorubulavirus and Rubella virus " \ "antigens (procedure)" @@ -136,7 +162,7 @@ let(:vaccine) { build(:vaccine, :mmrv) } it do - expect(snomed_procedure_term).to eq( + expect(vaccine.snomed_procedure_term).to eq( "Administration of vaccine product containing only Human " \ "alphaherpesvirus 3 and Measles morbillivirus and Mumps " \ "orthorubulavirus and Rubella virus antigens (procedure)" From 48d532696bd4710e08d57956fb2a86dd3c484aab Mon Sep 17 00:00:00 2001 From: Jake Benilov Date: Fri, 16 Jan 2026 12:44:46 +0000 Subject: [PATCH 06/63] Update SNOMED display term for flu vaccination across multiple JSON fixtures This makes the fixtures more true-to-life. JIRA: https://nhsd-jira.digital.nhs.uk/browse/MAV-3076 --- spec/fixtures/files/fhir/flu/fhir_record_full.json | 2 +- spec/fixtures/files/fhir/flu/fhir_record_half_dose.json | 2 +- spec/fixtures/files/fhir/flu/fhir_record_im_missing_dose.json | 2 +- spec/fixtures/files/fhir/flu/fhir_record_minimum.json | 2 +- .../files/fhir/flu/fhir_record_minimum_api_create.json | 2 +- .../files/fhir/flu/fhir_record_unexpected_dose_unit.json | 2 +- .../fixtures/files/fhir/flu/fhir_record_unknown_location.json | 2 +- spec/fixtures/files/fhir/flu/fhir_record_unknown_vaccine.json | 2 +- spec/fixtures/files/fhir/search_response_1_result.json | 2 +- spec/fixtures/files/fhir/search_response_1_result_mavis.json | 2 +- spec/fixtures/files/fhir/search_response_2_results.json | 4 ++-- .../files/fhir/search_response_2_results_mavis_duplicate.json | 4 ++-- ...rch_response_2_results_mavis_duplicate_primary_source.json | 4 ++-- spec/fixtures/files/fhir/search_response_all_programmes.json | 2 +- .../files/fhir/search_response_bad_immunization_target_1.json | 4 ++-- .../files/fhir/search_response_bad_immunization_target_2.json | 2 +- .../files/fhir/search_response_bad_immunization_target_3.json | 2 +- spec/fixtures/files/fhir/search_response_full_bundle.json | 4 ++-- .../files/fhir/search_response_good_immunization_target.json | 4 ++-- .../files/fhir/search_response_mismatching_bundle_link.json | 4 ++-- .../files/fhir/search_response_operation_outcome_error.json | 2 +- .../files/fhir/search_response_operation_outcome_fatal.json | 2 +- .../files/fhir/search_response_operation_outcome_success.json | 2 +- .../files/fhir/search_response_operation_outcome_warning.json | 2 +- 24 files changed, 31 insertions(+), 31 deletions(-) diff --git a/spec/fixtures/files/fhir/flu/fhir_record_full.json b/spec/fixtures/files/fhir/flu/fhir_record_full.json index b8afd9bdef..4dbece40d9 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_full.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_full.json @@ -202,7 +202,7 @@ ], "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination 222 (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ], "text": "Negative for Chlamydia Trachomatis rRNA Flu" diff --git a/spec/fixtures/files/fhir/flu/fhir_record_half_dose.json b/spec/fixtures/files/fhir/flu/fhir_record_half_dose.json index cd52e6a03e..502e4d9717 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_half_dose.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_half_dose.json @@ -202,7 +202,7 @@ ], "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination 222 (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ], "text": "Negative for Chlamydia Trachomatis rRNA Flu" diff --git a/spec/fixtures/files/fhir/flu/fhir_record_im_missing_dose.json b/spec/fixtures/files/fhir/flu/fhir_record_im_missing_dose.json index b571083fcb..58e6044685 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_im_missing_dose.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_im_missing_dose.json @@ -8,7 +8,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/flu/fhir_record_minimum.json b/spec/fixtures/files/fhir/flu/fhir_record_minimum.json index 97b9ef8b7f..9ccd5d8997 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_minimum.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_minimum.json @@ -20,7 +20,7 @@ ], "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination 222 (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/flu/fhir_record_minimum_api_create.json b/spec/fixtures/files/fhir/flu/fhir_record_minimum_api_create.json index ab1f484385..d0c66db2f6 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_minimum_api_create.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_minimum_api_create.json @@ -34,7 +34,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/flu/fhir_record_unexpected_dose_unit.json b/spec/fixtures/files/fhir/flu/fhir_record_unexpected_dose_unit.json index b1e870bc16..3086836ac2 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_unexpected_dose_unit.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_unexpected_dose_unit.json @@ -202,7 +202,7 @@ ], "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination 222 (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ], "text": "Negative for Chlamydia Trachomatis rRNA Flu" diff --git a/spec/fixtures/files/fhir/flu/fhir_record_unknown_location.json b/spec/fixtures/files/fhir/flu/fhir_record_unknown_location.json index 09c7f8022b..a69e66cec7 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_unknown_location.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_unknown_location.json @@ -44,7 +44,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/flu/fhir_record_unknown_vaccine.json b/spec/fixtures/files/fhir/flu/fhir_record_unknown_vaccine.json index 8cd9767060..9e7afae6e7 100644 --- a/spec/fixtures/files/fhir/flu/fhir_record_unknown_vaccine.json +++ b/spec/fixtures/files/fhir/flu/fhir_record_unknown_vaccine.json @@ -20,7 +20,7 @@ ], "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination 222 (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_1_result.json b/spec/fixtures/files/fhir/search_response_1_result.json index 45b32f8a39..117daaf1b8 100644 --- a/spec/fixtures/files/fhir/search_response_1_result.json +++ b/spec/fixtures/files/fhir/search_response_1_result.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_1_result_mavis.json b/spec/fixtures/files/fhir/search_response_1_result_mavis.json index e062edff14..58ef8194e2 100644 --- a/spec/fixtures/files/fhir/search_response_1_result_mavis.json +++ b/spec/fixtures/files/fhir/search_response_1_result_mavis.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_2_results.json b/spec/fixtures/files/fhir/search_response_2_results.json index de377e0c7e..e00f6de1d3 100644 --- a/spec/fixtures/files/fhir/search_response_2_results.json +++ b/spec/fixtures/files/fhir/search_response_2_results.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate.json b/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate.json index 9f5e203015..76cfd57cc8 100644 --- a/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate.json +++ b/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate_primary_source.json b/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate_primary_source.json index 86a2891d7d..743fd67194 100644 --- a/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate_primary_source.json +++ b/spec/fixtures/files/fhir/search_response_2_results_mavis_duplicate_primary_source.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_all_programmes.json b/spec/fixtures/files/fhir/search_response_all_programmes.json index 86dc914fe8..991fece0a5 100644 --- a/spec/fixtures/files/fhir/search_response_all_programmes.json +++ b/spec/fixtures/files/fhir/search_response_all_programmes.json @@ -168,7 +168,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_bad_immunization_target_1.json b/spec/fixtures/files/fhir/search_response_bad_immunization_target_1.json index f6dbfe5e46..e83e3b7110 100644 --- a/spec/fixtures/files/fhir/search_response_bad_immunization_target_1.json +++ b/spec/fixtures/files/fhir/search_response_bad_immunization_target_1.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_bad_immunization_target_2.json b/spec/fixtures/files/fhir/search_response_bad_immunization_target_2.json index bcbbe523d0..c1f5870a8b 100644 --- a/spec/fixtures/files/fhir/search_response_bad_immunization_target_2.json +++ b/spec/fixtures/files/fhir/search_response_bad_immunization_target_2.json @@ -169,7 +169,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_bad_immunization_target_3.json b/spec/fixtures/files/fhir/search_response_bad_immunization_target_3.json index 2196d4a1b9..3163867969 100644 --- a/spec/fixtures/files/fhir/search_response_bad_immunization_target_3.json +++ b/spec/fixtures/files/fhir/search_response_bad_immunization_target_3.json @@ -169,7 +169,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_full_bundle.json b/spec/fixtures/files/fhir/search_response_full_bundle.json index f6dbfe5e46..e83e3b7110 100644 --- a/spec/fixtures/files/fhir/search_response_full_bundle.json +++ b/spec/fixtures/files/fhir/search_response_full_bundle.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_good_immunization_target.json b/spec/fixtures/files/fhir/search_response_good_immunization_target.json index 13a10ec3e6..33487a016b 100644 --- a/spec/fixtures/files/fhir/search_response_good_immunization_target.json +++ b/spec/fixtures/files/fhir/search_response_good_immunization_target.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_mismatching_bundle_link.json b/spec/fixtures/files/fhir/search_response_mismatching_bundle_link.json index f6dbfe5e46..e83e3b7110 100644 --- a/spec/fixtures/files/fhir/search_response_mismatching_bundle_link.json +++ b/spec/fixtures/files/fhir/search_response_mismatching_bundle_link.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } @@ -145,7 +145,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_operation_outcome_error.json b/spec/fixtures/files/fhir/search_response_operation_outcome_error.json index 59ace131be..652c1a790c 100644 --- a/spec/fixtures/files/fhir/search_response_operation_outcome_error.json +++ b/spec/fixtures/files/fhir/search_response_operation_outcome_error.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_operation_outcome_fatal.json b/spec/fixtures/files/fhir/search_response_operation_outcome_fatal.json index 71b9c709e8..cd30fa5ae3 100644 --- a/spec/fixtures/files/fhir/search_response_operation_outcome_fatal.json +++ b/spec/fixtures/files/fhir/search_response_operation_outcome_fatal.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_operation_outcome_success.json b/spec/fixtures/files/fhir/search_response_operation_outcome_success.json index 8c779e71f5..5606bb7e59 100644 --- a/spec/fixtures/files/fhir/search_response_operation_outcome_success.json +++ b/spec/fixtures/files/fhir/search_response_operation_outcome_success.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } diff --git a/spec/fixtures/files/fhir/search_response_operation_outcome_warning.json b/spec/fixtures/files/fhir/search_response_operation_outcome_warning.json index 8ea7320676..59ce69d4bb 100644 --- a/spec/fixtures/files/fhir/search_response_operation_outcome_warning.json +++ b/spec/fixtures/files/fhir/search_response_operation_outcome_warning.json @@ -21,7 +21,7 @@ { "system": "http://snomed.info/sct", "code": "884861000000100", - "display": "Seasonal influenza vaccination (procedure)" + "display": "Administration of first intranasal seasonal influenza vaccination" } ] } From bcb49dfea73eb0883dc6740b41e8910f29fc3dcc Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Fri, 16 Jan 2026 14:45:20 +0000 Subject: [PATCH 07/63] Show GIAS year groups and other sites in CLI Schools::Show Adds information displayed in the command so we can quickly see any sites associated with the same URN and also GIAS year groups that might differ from the LocationProgrammeYearGroups associated with the school. --- app/lib/mavis_cli/schools/show.rb | 14 ++++++++++++++ spec/features/cli_schools_show_spec.rb | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/app/lib/mavis_cli/schools/show.rb b/app/lib/mavis_cli/schools/show.rb index 8347134c27..3a733296f5 100644 --- a/app/lib/mavis_cli/schools/show.rb +++ b/app/lib/mavis_cli/schools/show.rb @@ -63,6 +63,7 @@ def call( :site, :status, :gias_phase, + :gias_year_groups, :gias_establishment_number, :gias_local_authority_code, :address_line_1, @@ -140,6 +141,19 @@ def call( puts " #{Rainbow("year groups").bright}: #{year_groups.join(", ")}" end + puts "" + + if Location.where(urn: location.urn).count > 1 + puts "#{Rainbow("other locations with the same URN").bright}:" + Location + .where(urn: location.urn) + .find_each do |other_location| + next if other_location == location + + puts " #{Rainbow(other_location.urn_and_site).bright}: #{other_location.name}" + end + end + puts "" if locations.count > 1 end end diff --git a/spec/features/cli_schools_show_spec.rb b/spec/features/cli_schools_show_spec.rb index 5754a6ff27..ab75cedcc7 100644 --- a/spec/features/cli_schools_show_spec.rb +++ b/spec/features/cli_schools_show_spec.rb @@ -11,9 +11,11 @@ context "with just a URN" do it "displays the school details" do given_a_school_exists + and_a_site_with_the_same_urn_exists when_i_run_the_command then_the_school_details_are_displayed and_the_programme_year_groups_are_displayed + and_other_locations_with_the_same_urn_are_displayed end end @@ -62,6 +64,11 @@ def given_a_school_exists end end + def and_a_site_with_the_same_urn_exists + @site = + create(:school, name: "Test School Site B", urn: "123456", site: "B") + end + def and_the_school_has_patients_across_academic_years location = school = @school session = @@ -197,6 +204,11 @@ def and_the_programme_year_groups_are_displayed expect(@output).to match(/hpv:\s*year groups: 8, 9, 10, 11/) end + def and_other_locations_with_the_same_urn_are_displayed + expect(@output).to match(/other locations with the same URN:/) + expect(@output).to match(%r{ 123456B: Test School Site B}) + end + def then_the_correct_patient_counts_are_displayed expect(@output).to match(/^total patients: 7/) expect(@output).to match(/^ in current academic year: 3/) From 64c898a9c3dc25ddf4062b4cdfbeef627cbe1d29 Mon Sep 17 00:00:00 2001 From: Brage Gording Date: Thu, 15 Jan 2026 19:23:26 +0000 Subject: [PATCH 08/63] Create a basic setup for a WAF - DDoS provided OOTB - Add basic protection rules - Block known bad IPs - Add a rate limiter based on IP --- terraform/app/variables.tf | 31 ++++++++ terraform/app/waf.tf | 143 +++++++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 terraform/app/waf.tf diff --git a/terraform/app/variables.tf b/terraform/app/variables.tf index f0c58bfa8d..2372569f84 100644 --- a/terraform/app/variables.tf +++ b/terraform/app/variables.tf @@ -462,6 +462,37 @@ variable "valkey_log_retention_days" { } } +variable "waf_rule_actions" { + type = map(string) + default = { + core_rule_set = "COUNT" + known_bad_inputs = "COUNT" + ip_reputation_list = "COUNT" + rate_limiting = "COUNT" + } + description = "Map of WAF rule actions (COUNT/BLOCK)" + validation { + condition = alltrue([ + for action in values(var.waf_rule_actions) : contains(["COUNT", "BLOCK"], action) + ]) + error_message = "Valid values: COUNT or BLOCK" + } +} + +variable "waf_logging_enabled" { + type = bool + default = true + description = "Enable WAF request logging" + nullable = false +} + +variable "waf_rate_limit_threshold" { + type = number + default = 100 + description = "Rate limit threshold for WAF in requests per 5 minutes" + nullable = false +} + variable "active_target_group" { default = "blue" diff --git a/terraform/app/waf.tf b/terraform/app/waf.tf new file mode 100644 index 0000000000..0bd728e064 --- /dev/null +++ b/terraform/app/waf.tf @@ -0,0 +1,143 @@ +resource "aws_wafv2_web_acl" "mavis_waf" { + name = "mavis-waf-${var.environment}" + description = "WAF ACL for Mavis application" + scope = "REGIONAL" + + default_action { + allow {} + } + + visibility_config { + cloudwatch_metrics_enabled = var.waf_logging_enabled + metric_name = "mavis-waf-${var.environment}" + sampled_requests_enabled = var.waf_logging_enabled + } + + lifecycle { + ignore_changes = [rule] + } +} + +resource "aws_wafv2_web_acl_association" "alb_association" { + resource_arn = aws_lb.app_lb.arn + web_acl_arn = aws_wafv2_web_acl.mavis_waf.arn + + depends_on = [aws_wafv2_web_acl_rule_group_association.rate_limit] +} + +resource "aws_cloudwatch_log_group" "waf_logs" { + count = var.waf_logging_enabled ? 1 : 0 + name = "aws-waf-logs-mavis-${var.environment}" + retention_in_days = 30 +} + +resource "aws_wafv2_web_acl_logging_configuration" "mavis_waf_logging" { + count = var.waf_logging_enabled ? 1 : 0 + log_destination_configs = [aws_cloudwatch_log_group.waf_logs[0].arn] + resource_arn = aws_wafv2_web_acl.mavis_waf.arn + + depends_on = [aws_cloudwatch_log_group.waf_logs] +} + +######### WAF RULE GROUP ASSOCIATIONS ######### + +resource "aws_wafv2_web_acl_rule_group_association" "ip_reputation_list" { + rule_name = "AWSManagedRulesAmazonIpReputationList" + priority = 10 + web_acl_arn = aws_wafv2_web_acl.mavis_waf.arn + + managed_rule_group { + name = "AWSManagedRulesAmazonIpReputationList" + vendor_name = "AWS" + } + + override_action = var.waf_rule_actions["ip_reputation_list"] == "COUNT" ? "count" : "none" + + depends_on = [aws_wafv2_web_acl.mavis_waf] +} + +resource "aws_wafv2_web_acl_rule_group_association" "common_rule_set" { + rule_name = "AWSManagedRulesCommonRuleSet" + priority = 20 + web_acl_arn = aws_wafv2_web_acl.mavis_waf.arn + + managed_rule_group { + name = "AWSManagedRulesCommonRuleSet" + vendor_name = "AWS" + } + + override_action = var.waf_rule_actions["core_rule_set"] == "COUNT" ? "count" : "none" + + depends_on = [aws_wafv2_web_acl_rule_group_association.ip_reputation_list] +} + +resource "aws_wafv2_web_acl_rule_group_association" "known_bad_inputs" { + rule_name = "AWSManagedRulesKnownBadInputsRuleSet" + priority = 30 + web_acl_arn = aws_wafv2_web_acl.mavis_waf.arn + + managed_rule_group { + name = "AWSManagedRulesKnownBadInputsRuleSet" + vendor_name = "AWS" + } + + override_action = var.waf_rule_actions["known_bad_inputs"] == "COUNT" ? "count" : "none" + + depends_on = [aws_wafv2_web_acl_rule_group_association.common_rule_set] +} + +resource "aws_wafv2_web_acl_rule_group_association" "rate_limit" { + rule_name = "RateLimitRule" + priority = 40 + web_acl_arn = aws_wafv2_web_acl.mavis_waf.arn + + rule_group_reference { + arn = aws_wafv2_rule_group.rate_limit_group.arn + } + depends_on = [aws_wafv2_web_acl_rule_group_association.known_bad_inputs] +} + + +######### CUSTOM RULE GROUPS ######### + +resource "aws_wafv2_rule_group" "rate_limit_group" { + name = "mavis-rate-limit-${var.environment}" + scope = "REGIONAL" + capacity = 2 + + rule { + name = "RateLimitRule" + priority = 0 + + action { + dynamic "count" { + for_each = var.waf_rule_actions["rate_limiting"] == "COUNT" ? [1] : [] + content {} + } + dynamic "block" { + for_each = var.waf_rule_actions["rate_limiting"] == "BLOCK" ? [1] : [] + content {} + } + } + + statement { + rate_based_statement { + limit = var.waf_rate_limit_threshold + aggregate_key_type = "IP" + evaluation_window_sec = 60 + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "RateLimitRule" + sampled_requests_enabled = true + } + } + + visibility_config { + cloudwatch_metrics_enabled = true + metric_name = "mavis-rate-limit-${var.environment}" + sampled_requests_enabled = true + } +} \ No newline at end of file From b5f58d43338695d7d694644beae295882468272c Mon Sep 17 00:00:00 2001 From: Brage Gording Date: Fri, 16 Jan 2026 14:14:34 +0000 Subject: [PATCH 09/63] Update permissions to allow for deploying WAF - Required to execute this as a pre-deployment step --- .../iam_policy_DeployMavisResources.json | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/terraform/account/resources/iam_policy_DeployMavisResources.json b/terraform/account/resources/iam_policy_DeployMavisResources.json index 6de1b7206b..cc1f804f73 100644 --- a/terraform/account/resources/iam_policy_DeployMavisResources.json +++ b/terraform/account/resources/iam_policy_DeployMavisResources.json @@ -82,6 +82,11 @@ "elasticloadbalancing:ModifyTargetGroupAttributes", "elasticloadbalancing:AddListenerCertificates", "elasticloadbalancing:RemoveListenerCertificates", + "elasticloadbalancing:SetWebAcl", + "elasticloadbalancing:CreateWebACLAssociation", + "elasticloadbalancing:DeleteWebACLAssociation", + "elasticloadbalancing:DescribeWebACLAssociation", + "elasticloadbalancing:GetLoadBalancerWebACL", "iam:AttachRolePolicy", "iam:CreatePolicyVersion", "iam:PassRole", @@ -179,7 +184,25 @@ "SNS:DeleteTopic", "SNS:SetTopicAttributes", "SNS:Subscribe", - "SNS:Unsubscribe" + "SNS:Unsubscribe", + "wafv2:AssociateWebACL", + "wafv2:CreateIPSet", + "wafv2:CreateRegexPatternSet", + "wafv2:CreateRuleGroup", + "wafv2:CreateWebACL", + "wafv2:DeleteIPSet", + "wafv2:DeleteLoggingConfiguration", + "wafv2:DeleteRegexPatternSet", + "wafv2:DeleteRuleGroup", + "wafv2:DeleteWebACL", + "wafv2:DisassociateWebACL", + "wafv2:PutLoggingConfiguration", + "wafv2:UpdateIPSet", + "wafv2:PutManagedRuleSetVersions", + "wafv2:UpdateManagedRuleSetVersionExpiryDate", + "wafv2:UpdateWebACL", + "wafv2:UpdateRuleGroup", + "wafv2:UpdateRegexPatternSet" ], "Resource": ["*"] } From 166397fa0cdb2ac2d0ea967ad990b3937d6e0864 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 18 Dec 2025 10:30:27 +0000 Subject: [PATCH 10/63] Remove `has_triage_status` scope This removes the scope and replaces it usage with `has_programme_status`. Jira-Issue: MAV-2660 --- .../app_programme_session_table_component.rb | 4 +-- .../app_session_actions_component.rb | 4 +-- .../patient_specific_directions_controller.rb | 9 +++++-- app/models/patient.rb | 26 ------------------- spec/features/cli_generate_consents_spec.rb | 18 +++---------- 5 files changed, 15 insertions(+), 46 deletions(-) diff --git a/app/components/app_programme_session_table_component.rb b/app/components/app_programme_session_table_component.rb index 0c2ece9b8f..ce952874e3 100644 --- a/app/components/app_programme_session_table_component.rb +++ b/app/components/app_programme_session_table_component.rb @@ -38,8 +38,8 @@ def no_response_percentage(session:) def triage_needed_count(session:) format_number( - patients(session:).has_triage_status( - :required, + patients(session:).has_programme_status( + "needs_triage", programme:, academic_year: ).count diff --git a/app/components/app_session_actions_component.rb b/app/components/app_session_actions_component.rb index 0ecd8052c2..f643b3157b 100644 --- a/app/components/app_session_actions_component.rb +++ b/app/components/app_session_actions_component.rb @@ -90,8 +90,8 @@ def conflicting_consent_row def triage_required_row count = - patients.has_triage_status( - "required", + patients.has_programme_status( + "needs_triage", programme: programmes, academic_year: ).count diff --git a/app/controllers/sessions/patient_specific_directions_controller.rb b/app/controllers/sessions/patient_specific_directions_controller.rb index a9f67fd140..95d6b03ba3 100644 --- a/app/controllers/sessions/patient_specific_directions_controller.rb +++ b/app/controllers/sessions/patient_specific_directions_controller.rb @@ -72,11 +72,16 @@ def patients_allowed_psd academic_year: @session.academic_year, vaccine_method: "nasal" ) - .has_triage_status( - "not_required", + .has_programme_status( + "due", programme: @programme, academic_year: @session.academic_year ) + .has_vaccine_criteria( + programme: @programme, + academic_year: @session.academic_year, + vaccine_methods: %w[nasal] + ) .without_patient_specific_direction( programme: @programme, academic_year: @session.academic_year, diff --git a/app/models/patient.rb b/app/models/patient.rb index 22f0b1b70a..7e6d53e695 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -323,32 +323,6 @@ class Patient < ApplicationRecord where(consent_status_scope.arel.exists) end - scope :has_triage_status, - ->( - status, - programme:, - academic_year:, - vaccine_method: nil, - without_gelatine: nil - ) do - triage_status_scope = - Patient::TriageStatus - .select("1") - .where("patient_id = patients.id") - .for_programmes(Array(programme)) - .where(status:, academic_year:) - - unless vaccine_method.nil? - triage_status_scope = triage_status_scope.where(vaccine_method:) - end - - unless without_gelatine.nil? - triage_status_scope = triage_status_scope.where(without_gelatine:) - end - - where(triage_status_scope.arel.exists) - end - scope :has_vaccine_criteria, ->( programme:, diff --git a/spec/features/cli_generate_consents_spec.rb b/spec/features/cli_generate_consents_spec.rb index d20ddcef3e..3eccc6db2a 100644 --- a/spec/features/cli_generate_consents_spec.rb +++ b/spec/features/cli_generate_consents_spec.rb @@ -104,13 +104,8 @@ def then_one_of_each_consent_is_created_across_sessions expect( @team .patients - .has_consent_status( - :given, - programme: @programme, - academic_year: AcademicYear.current - ) - .has_triage_status( - :not_required, + .has_programme_status( + "due", programme: @programme, academic_year: AcademicYear.current ) @@ -119,13 +114,8 @@ def then_one_of_each_consent_is_created_across_sessions expect( @team .patients - .has_consent_status( - :given, - programme: @programme, - academic_year: AcademicYear.current - ) - .has_triage_status( - :required, + .has_programme_status( + "due", programme: @programme, academic_year: AcademicYear.current ) From d5893ee0edb6d209eedb8e56265d67833fa3f377 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 19 Dec 2025 12:59:22 +0000 Subject: [PATCH 11/63] Remove `has_vaccination_status` scope This removes the scope and replaces it usage with `has_programme_status`. Jira-Issue: MAV-2660 --- app/jobs/invalidate_self_consents_job.rb | 7 +----- ..._refused_consent_already_vaccinated_job.rb | 5 ++-- app/lib/stats/organisations.rb | 4 ++-- app/models/patient.rb | 13 ---------- .../cli_generate_vaccination_records_spec.rb | 4 ++-- .../jobs/invalidate_self_consents_job_spec.rb | 24 +++++++++++++------ 6 files changed, 24 insertions(+), 33 deletions(-) diff --git a/app/jobs/invalidate_self_consents_job.rb b/app/jobs/invalidate_self_consents_job.rb index 7d61dcc232..b3122980e4 100644 --- a/app/jobs/invalidate_self_consents_job.rb +++ b/app/jobs/invalidate_self_consents_job.rb @@ -8,12 +8,7 @@ def perform academic_year = AcademicYear.current programmes.each do |programme| - patients = - Patient.has_vaccination_status( - %i[not_eligible eligible due], - programme:, - academic_year: - ) + patients = Patient.has_programme_status("due", programme:, academic_year:) Team.find_each do |team| consents = diff --git a/app/jobs/patients_refused_consent_already_vaccinated_job.rb b/app/jobs/patients_refused_consent_already_vaccinated_job.rb index 7d35cfb643..4f9b7b123b 100644 --- a/app/jobs/patients_refused_consent_already_vaccinated_job.rb +++ b/app/jobs/patients_refused_consent_already_vaccinated_job.rb @@ -36,12 +36,11 @@ def patients_with_consent_refused(programme) Patient .includes(parent_relationships: :parent) .appear_in_programmes([programme], academic_year:) - .has_vaccination_status( - %w[not_eligible eligible due], + .has_programme_status( + "has_refusal_consent_refused", programme:, academic_year: ) - .has_consent_status("refused", programme:, academic_year:) end def should_record_already_vaccinated?(consents:) diff --git a/app/lib/stats/organisations.rb b/app/lib/stats/organisations.rb index 8d37f71601..42f819fa8f 100644 --- a/app/lib/stats/organisations.rb +++ b/app/lib/stats/organisations.rb @@ -147,8 +147,8 @@ def calculate_vaccination_stats(programme) eligible_patients = get_eligible_patients(programme) vaccinated_patients = - eligible_patients.has_vaccination_status( - :vaccinated, + eligible_patients.has_programme_status( + Patient::ProgrammeStatus::VACCINATED_STATUSES.keys, programme:, academic_year: ) diff --git a/app/models/patient.rb b/app/models/patient.rb index 7e6d53e695..77cbab5d3b 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -283,19 +283,6 @@ class Patient < ApplicationRecord where(programme_status_scope.arel.exists) end - scope :has_vaccination_status, - ->(status, programme:, academic_year:) do - where( - Patient::VaccinationStatus - .select("1") - .where("patient_id = patients.id") - .for_programmes(Array(programme)) - .where(status:, academic_year:) - .arel - .exists - ) - end - scope :has_consent_status, ->( status, diff --git a/spec/features/cli_generate_vaccination_records_spec.rb b/spec/features/cli_generate_vaccination_records_spec.rb index b01fa7696d..974cee65c0 100644 --- a/spec/features/cli_generate_vaccination_records_spec.rb +++ b/spec/features/cli_generate_vaccination_records_spec.rb @@ -107,8 +107,8 @@ def then_the_administered_vaccination_records_is_created_for_that_session end def vaccination_records_for(team_or_session) - team_or_session.reload.patients.has_vaccination_status( - :vaccinated, + team_or_session.reload.patients.has_programme_status( + Patient::ProgrammeStatus::VACCINATED_STATUSES.keys, programme: @programme, academic_year: AcademicYear.current ) diff --git a/spec/jobs/invalidate_self_consents_job_spec.rb b/spec/jobs/invalidate_self_consents_job_spec.rb index bea3be3f14..d0e25d3537 100644 --- a/spec/jobs/invalidate_self_consents_job_spec.rb +++ b/spec/jobs/invalidate_self_consents_job_spec.rb @@ -11,7 +11,7 @@ context "with parental consent from yesterday" do let(:consent) { create(:consent, academic_year:, created_at: 1.day.ago) } - before { create(:patient_vaccination_status, patient:, programme:) } + before { create(:patient_programme_status, patient:, programme:) } it "does not invalidate the consent" do expect { perform_now }.not_to(change { consent.reload.invalidated? }) @@ -39,7 +39,7 @@ context "with parental consent from today" do let(:consent) { create(:consent, academic_year:) } - before { create(:patient_vaccination_status, patient:, programme:) } + before { create(:patient_programme_status, patient:, programme:) } it "does not invalidate the consent" do expect { perform_now }.not_to(change { consent.reload.invalidated? }) @@ -68,7 +68,7 @@ create(:consent, :self_consent, academic_year:, created_at: 1.day.ago) end - before { create(:patient_vaccination_status, patient:, programme:) } + before { create(:patient_programme_status, :due, patient:, programme:) } it "invalidates the consent" do expect { perform_now }.to change { consent.reload.invalidated? }.from( @@ -144,7 +144,7 @@ created_at: 1.day.ago ) - patient.vaccination_statuses.update_all(status: :vaccinated) + StatusUpdater.call(patient:) end it "does not invalidate the consent" do @@ -174,7 +174,7 @@ context "with self-consent from today" do let(:consent) { create(:consent, :self_consent, academic_year:) } - before { create(:patient_vaccination_status, patient:, programme:) } + before { create(:patient_programme_status, patient:, programme:) } it "does not invalidate the consent" do expect { perform_now }.not_to(change { consent.reload.invalidated? }) @@ -229,8 +229,18 @@ end before do - create(:patient_vaccination_status, patient:, programme: self_programme) - create(:patient_vaccination_status, patient:, programme: parent_programme) + create( + :patient_programme_status, + :due, + patient:, + programme: self_programme + ) + create( + :patient_programme_status, + :due, + patient:, + programme: parent_programme + ) end it "does not invalidate the parent consent" do From c83c5b61f0b0475509fb753c40eae8daee481f4e Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 19 Dec 2025 13:08:32 +0000 Subject: [PATCH 12/63] Remove `has_consent_status` scope This removes the scope and replaces it usage with `has_programme_status`. Jira-Issue: MAV-2660 --- .../app_programme_session_table_component.rb | 4 +-- .../app_session_actions_component.rb | 4 +-- .../patient_specific_directions_controller.rb | 8 +----- app/lib/generate/vaccination_records.rb | 2 +- app/lib/stats/organisations.rb | 22 ++++++++++----- app/models/concerns/has_vaccine_methods.rb | 8 ------ app/models/patient.rb | 27 ------------------- app/models/session.rb | 4 +-- spec/factories/patients.rb | 10 +++++++ spec/features/cli_generate_consents_spec.rb | 4 +-- 10 files changed, 36 insertions(+), 57 deletions(-) diff --git a/app/components/app_programme_session_table_component.rb b/app/components/app_programme_session_table_component.rb index ce952874e3..828608a21a 100644 --- a/app/components/app_programme_session_table_component.rb +++ b/app/components/app_programme_session_table_component.rb @@ -18,8 +18,8 @@ def cohort_count(session:) end def no_response_scope(session:) - patients(session:).has_consent_status( - :no_response, + patients(session:).has_programme_status( + "needs_consent_no_response", programme:, academic_year: ) diff --git a/app/components/app_session_actions_component.rb b/app/components/app_session_actions_component.rb index f643b3157b..b9a1723db3 100644 --- a/app/components/app_session_actions_component.rb +++ b/app/components/app_session_actions_component.rb @@ -72,8 +72,8 @@ def no_consent_response_row def conflicting_consent_row count = - patients.has_consent_status( - "conflicts", + patients.has_programme_status( + "has_refusal_consent_conflicts", programme: programmes, academic_year: ).count diff --git a/app/controllers/sessions/patient_specific_directions_controller.rb b/app/controllers/sessions/patient_specific_directions_controller.rb index 95d6b03ba3..5756e08f97 100644 --- a/app/controllers/sessions/patient_specific_directions_controller.rb +++ b/app/controllers/sessions/patient_specific_directions_controller.rb @@ -66,12 +66,6 @@ def patients_allowed_psd @patients_allowed_psd ||= @session .patients - .has_consent_status( - "given", - programme: @programme, - academic_year: @session.academic_year, - vaccine_method: "nasal" - ) .has_programme_status( "due", programme: @programme, @@ -80,7 +74,7 @@ def patients_allowed_psd .has_vaccine_criteria( programme: @programme, academic_year: @session.academic_year, - vaccine_methods: %w[nasal] + vaccine_methods: [%w[nasal], %w[nasal injection]] ) .without_patient_specific_direction( programme: @programme, diff --git a/app/lib/generate/vaccination_records.rb b/app/lib/generate/vaccination_records.rb index ea98b685e1..abc3f37461 100644 --- a/app/lib/generate/vaccination_records.rb +++ b/app/lib/generate/vaccination_records.rb @@ -129,7 +129,7 @@ def patients_for(session:) .patients .includes_statuses .appear_in_programmes([programme], academic_year:) - .has_consent_status("given", programme:, academic_year:) + .has_programme_status("due", programme:, academic_year:) .select do it.consent_given_and_safe_to_vaccinate?(programme:, academic_year:) end diff --git a/app/lib/stats/organisations.rb b/app/lib/stats/organisations.rb index 42f819fa8f..c5e442aceb 100644 --- a/app/lib/stats/organisations.rb +++ b/app/lib/stats/organisations.rb @@ -78,21 +78,31 @@ def calculate_consent_stats(programme) .count patients_with_no_response = - eligible_patients.has_consent_status( - :no_response, + eligible_patients.has_programme_status( + "needs_consent_no_response", programme:, academic_year: ) + # TODO: This is not an exact match for "Consent given", replace with a + # better solution. patients_with_response_given = - eligible_patients.has_consent_status(:given, programme:, academic_year:) + eligible_patients.has_programme_status( + %w[due needs_triage vaccinated_fully], + programme:, + academic_year: + ) patients_with_response_refused = - eligible_patients.has_consent_status(:refused, programme:, academic_year:) + eligible_patients.has_programme_status( + "has_refusal_consent_refused", + programme:, + academic_year: + ) patients_with_response_conflicting = - eligible_patients.has_consent_status( - :conflicts, + eligible_patients.has_programme_status( + "has_refusal_consent_conflicts", programme:, academic_year: ) diff --git a/app/models/concerns/has_vaccine_methods.rb b/app/models/concerns/has_vaccine_methods.rb index c7114427a1..0dafd0f3ba 100644 --- a/app/models/concerns/has_vaccine_methods.rb +++ b/app/models/concerns/has_vaccine_methods.rb @@ -9,14 +9,6 @@ module HasVaccineMethods array_enum vaccine_methods: { injection: 0, nasal: 1 } validates :vaccine_methods, subset: vaccine_methods.keys - - scope :has_vaccine_method, - ->(vaccine_method) do - where( - "vaccine_methods[1] IN (?)", - Array(vaccine_method).map { vaccine_methods.fetch(it) } - ) - end end def vaccine_method_injection? = vaccine_methods.include?("injection") diff --git a/app/models/patient.rb b/app/models/patient.rb index 77cbab5d3b..f56d135194 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -283,33 +283,6 @@ class Patient < ApplicationRecord where(programme_status_scope.arel.exists) end - scope :has_consent_status, - ->( - status, - programme:, - academic_year:, - vaccine_method: nil, - without_gelatine: nil - ) do - consent_status_scope = - Patient::ConsentStatus - .select("1") - .where("patient_id = patients.id") - .for_programmes(Array(programme)) - .where(status:, academic_year:) - - unless vaccine_method.nil? - consent_status_scope = - consent_status_scope.has_vaccine_method(vaccine_method) - end - - unless without_gelatine.nil? - consent_status_scope = consent_status_scope.where(without_gelatine:) - end - - where(consent_status_scope.arel.exists) - end - scope :has_vaccine_criteria, ->( programme:, diff --git a/app/models/session.rb b/app/models/session.rb index 5b31c5f7de..d0fc5ad636 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -278,8 +278,8 @@ def can_send_clinic_invitations? end def patients_with_no_consent_response_count - patients.has_consent_status( - "no_response", + patients.has_programme_status( + "needs_consent_no_response", programme: programmes, academic_year: ).count diff --git a/spec/factories/patients.rb b/spec/factories/patients.rb index b706787ba4..74d7dca8ee 100644 --- a/spec/factories/patients.rb +++ b/spec/factories/patients.rb @@ -965,6 +965,16 @@ ) end end + programme_statuses do + programmes.map do |programme| + association( + :patient_programme_status, + :has_refusal_consent_conflicts, + patient: instance, + programme: + ) + end + end end trait :consent_not_provided do diff --git a/spec/features/cli_generate_consents_spec.rb b/spec/features/cli_generate_consents_spec.rb index 3eccc6db2a..73fecc6192 100644 --- a/spec/features/cli_generate_consents_spec.rb +++ b/spec/features/cli_generate_consents_spec.rb @@ -124,8 +124,8 @@ def then_one_of_each_consent_is_created_across_sessions expect( @team .patients - .has_consent_status( - :refused, + .has_programme_status( + "has_refusal_consent_refused", programme: @programme, academic_year: AcademicYear.current ) From 2260acdbb3fe6f56dc69ed95816fce40e74ec820 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 7 Jan 2026 13:22:16 +0000 Subject: [PATCH 13/63] Update `has_vaccine_criteria` scope This updates the scope to use the `Patient::ProgrammeStatus` model to filter on vaccine criteria rather than the `Patient::ConsentStatus` and `Patient::TriageStatus` models, allowing us to eventually remove these models. Jira-Issue: MAV-2660 --- app/models/patient.rb | 88 ++++--------------- .../filtering_by_vaccine_type_spec.rb | 2 +- spec/forms/patient_search_form_spec.rb | 4 +- 3 files changed, 19 insertions(+), 75 deletions(-) diff --git a/app/models/patient.rb b/app/models/patient.rb index f56d135194..5cec5613ec 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -290,112 +290,56 @@ class Patient < ApplicationRecord vaccine_methods: nil, without_gelatine: nil ) do - triage_status_matching = - Patient::TriageStatus - .select("1") - .where("patient_id = patients.id") - .for_programmes(Array(programme)) - .where(academic_year:) - - triage_status_not_required = - Patient::TriageStatus - .select("1") - .where("patient_id = patients.id") - .for_programmes(Array(programme)) - .where(academic_year:) - .where(status: "not_required") - - consent_status_matching = - Patient::ConsentStatus + programme_status_scope = + Patient::ProgrammeStatus .select("1") .where("patient_id = patients.id") .for_programmes(Array(programme)) .where(academic_year:) unless vaccine_methods.nil? - # For triage, nurses select a single vaccine method, so we need - # to filter out when asking for two or more vaccine methods. - # This code is needed to handle both where an array of arrays is - # passed in or a single array of strings. - if vaccine_methods.empty? || vaccine_methods.all? { it.is_a?(String) } - consent_status_matching = - consent_status_matching.where( + programme_status_scope = + programme_status_scope.where( vaccine_methods: vaccine_methods.map do - Patient::ConsentStatus.vaccine_methods.fetch(it) + Patient::ProgrammeStatus.vaccine_methods.fetch(it) end ) - - triage_status_matching = - if vaccine_methods.count == 1 - triage_status_matching.where( - vaccine_method: vaccine_methods.first - ) - else - triage_status_matching.none - end else - consent_or_scope = - consent_status_matching.where( + or_scope = + programme_status_scope.where( vaccine_methods: vaccine_methods.first.map do - Patient::ConsentStatus.vaccine_methods.fetch(it) + Patient::ProgrammeStatus.vaccine_methods.fetch(it) end ) - triage_or_scope = - if vaccine_methods.first.count == 1 - triage_status_matching.where( - vaccine_method: vaccine_methods.first.first - ) - else - triage_status_matching.none - end - vaccine_methods .drop(1) .each do |value| - consent_or_scope = - consent_or_scope.or( - consent_status_matching.where( + or_scope = + or_scope.or( + programme_status_scope.where( vaccine_methods: value.map do - Patient::ConsentStatus.vaccine_methods.fetch(it) + Patient::ProgrammeStatus.vaccine_methods.fetch(it) end ) ) - - triage_or_scope = - if values.first.count == 1 - triage_or_scope.or( - triage_status_matching.where( - vaccine_method: value.first - ) - ) - else - triage_or_scope.or(triage_status_matching.none) - end end - consent_status_matching = consent_or_scope - triage_status_matching = triage_or_scope + programme_status_scope = or_scope end end unless without_gelatine.nil? - triage_status_matching = - triage_status_matching.where(without_gelatine:) - consent_status_matching = - consent_status_matching.where(without_gelatine:) + programme_status_scope = + programme_status_scope.where(without_gelatine:) end - where(triage_status_matching.arel.exists).or( - where(triage_status_not_required.arel.exists).where( - consent_status_matching.arel.exists - ) - ) + where(programme_status_scope.arel.exists) end scope :has_registration_status, diff --git a/spec/features/filtering_by_vaccine_type_spec.rb b/spec/features/filtering_by_vaccine_type_spec.rb index 65e046c5d2..ba10894664 100644 --- a/spec/features/filtering_by_vaccine_type_spec.rb +++ b/spec/features/filtering_by_vaccine_type_spec.rb @@ -104,7 +104,7 @@ def and_patients_are_in_the_flu_hpv_session session: @session ).tap do it - .consent_statuses + .programme_statuses .find_by(programme_type: @session.programme_types.first) .update!(without_gelatine: true) end diff --git a/spec/forms/patient_search_form_spec.rb b/spec/forms/patient_search_form_spec.rb index 2de2ea740b..597f27fb70 100644 --- a/spec/forms/patient_search_form_spec.rb +++ b/spec/forms/patient_search_form_spec.rb @@ -257,7 +257,7 @@ nasal_patient = create(:patient, :consent_given_triage_not_needed, session:) - nasal_patient.consent_statuses.first.update!( + nasal_patient.programme_statuses.first.update!( vaccine_methods: %w[nasal injection] ) @@ -274,7 +274,7 @@ injection_primary_patient = create(:patient, :consent_given_triage_not_needed, session:) - injection_primary_patient.consent_statuses.first.update!( + injection_primary_patient.programme_statuses.first.update!( vaccine_methods: %w[injection nasal] ) From dd38f1da4750d46ba3cc16a330e7bc7257f7675f Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 16 Jan 2026 17:02:27 +0000 Subject: [PATCH 14/63] Use `safe_join` instead of `join(...).html_safe` This ensures that any values being joined together are escaped correctly and don't result in XSS vulnerabilities. --- app/helpers/parents_helper.rb | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/helpers/parents_helper.rb b/app/helpers/parents_helper.rb index 3a3b842cfe..fe50c4a077 100644 --- a/app/helpers/parents_helper.rb +++ b/app/helpers/parents_helper.rb @@ -14,14 +14,17 @@ def format_parents_with_relationships(parent_relationships) def format_parent_with_relationship(parent_relationship, include_phone: true) parent = parent_relationship.parent - [ - parent_relationship.label_with_parent, - if (email = parent.email).present? - tag.span(email, class: "nhsuk-u-secondary-text-colour") - end, - if include_phone && (phone = parent.phone).present? - tag.span(phone, class: "nhsuk-u-secondary-text-colour") - end - ].compact.join(tag.br).html_safe + safe_join( + [ + parent_relationship.label_with_parent, + if (email = parent.email).present? + tag.span(email, class: "nhsuk-u-secondary-text-colour") + end, + if include_phone && (phone = parent.phone).present? + tag.span(phone, class: "nhsuk-u-secondary-text-colour") + end + ].compact, + tag.br + ) end end From 446ba7fb0fe04a8b69718174a6a2c61fb9e3f112 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:53:37 +0000 Subject: [PATCH 15/63] Bump @hotwired/turbo-rails from 8.0.20 to 8.0.21 Bumps [@hotwired/turbo-rails](https://github.com/hotwired/turbo-rails) from 8.0.20 to 8.0.21. - [Release notes](https://github.com/hotwired/turbo-rails/releases) - [Commits](https://github.com/hotwired/turbo-rails/commits/v8.0.21) --- updated-dependencies: - dependency-name: "@hotwired/turbo-rails" dependency-version: 8.0.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d581e35ed6..144f4b3ca0 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "app", "private": "true", "dependencies": { - "@hotwired/turbo-rails": "^8.0.20", + "@hotwired/turbo-rails": "^8.0.21", "accessible-autocomplete": "^3.0.1", "esbuild": "^0.27.2", "idb": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index 23fcd2fc53..ec03329629 100644 --- a/yarn.lock +++ b/yarn.lock @@ -523,18 +523,18 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== -"@hotwired/turbo-rails@^8.0.20": - version "8.0.20" - resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.20.tgz#a6f6f78591e9868ca1e5e67f4c7d453dbd49a475" - integrity sha512-4aYkYF9XMKL7ZZPfgElq15+60osZOwMwhztE4myKQYEzCPvaPUxwZH301tOrBNtWUwOD+TNOm1Hrpeaq22RX9A== +"@hotwired/turbo-rails@^8.0.21": + version "8.0.21" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.21.tgz#403bfb2b1b92a921d854471772a7aa906f75ab04" + integrity sha512-RUnqt7tOVlQT539eVqFaAp4/32y7sLxvt3G7OIMROVlIMWvM8CXJk08xvIro2ueQViYRB6jwRjruNx7drIJPXg== dependencies: - "@hotwired/turbo" "^8.0.20" + "@hotwired/turbo" "^8.0.21" "@rails/actioncable" ">=7.0" -"@hotwired/turbo@^8.0.20": - version "8.0.20" - resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.20.tgz#068ede648c4db09fed4cf0ac0266788056673f2f" - integrity sha512-IilkH/+h92BRLeY/rMMR3MUh1gshIfdra/qZzp/Bl5FmiALD/6sQZK/ecxSbumeyOYiWr/JRI+Au1YQmkJGnoA== +"@hotwired/turbo@^8.0.21": + version "8.0.21" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.21.tgz#a3e80c01d70048200f64bbe3582b84f9bfac034e" + integrity sha512-fJTv3JnzFHeDxBb23esZSOhT4r142xf5o3lKMFMvzPC6AllkqbBKk5Yb31UZhtIsKQCwmO/pUQrtTUlYl5CHAQ== "@isaacs/cliui@^8.0.2": version "8.0.2" From 3e6808e0cd6ce482516d6baf903c4a5af1079054 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:54:38 +0000 Subject: [PATCH 16/63] Bump turbo-rails from 2.0.20 to 2.0.21 Bumps [turbo-rails](https://github.com/hotwired/turbo-rails) from 2.0.20 to 2.0.21. - [Release notes](https://github.com/hotwired/turbo-rails/releases) - [Commits](https://github.com/hotwired/turbo-rails/compare/v2.0.20...v2.0.21) --- updated-dependencies: - dependency-name: turbo-rails dependency-version: 2.0.21 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e8cad7beb4..7a3978da39 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -741,7 +741,7 @@ GEM tilt (2.7.0) timeout (0.6.0) tsort (0.2.0) - turbo-rails (2.0.20) + turbo-rails (2.0.21) actionpack (>= 7.1.0) railties (>= 7.1.0) tzinfo (2.0.6) From f545cd94637c19bf7ef3c0acbec3e6816b0929a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:55:45 +0000 Subject: [PATCH 17/63] Bump aws-sdk-rds from 1.305.0 to 1.306.0 Bumps [aws-sdk-rds](https://github.com/aws/aws-sdk-ruby) from 1.305.0 to 1.306.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-rds/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-rds dependency-version: 1.306.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e8cad7beb4..ec846bc065 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,11 +136,11 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1205.0) + aws-partitions (1.1206.0) aws-sdk-accessanalyzer (1.84.0) aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-core (3.241.3) + aws-sdk-core (3.241.4) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -160,8 +160,8 @@ GEM aws-sdk-kms (1.120.0) aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-rds (1.305.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-rds (1.306.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-s3 (1.211.0) aws-sdk-core (~> 3, >= 3.241.3) From dedb40e21949eafa9bd0ab2af9c730bdad231737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:56:08 +0000 Subject: [PATCH 18/63] Bump aws-sdk-iam from 1.139.0 to 1.140.0 Bumps [aws-sdk-iam](https://github.com/aws/aws-sdk-ruby) from 1.139.0 to 1.140.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-iam/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-iam dependency-version: 1.140.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e8cad7beb4..f5fc5d1714 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,11 +136,11 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1205.0) + aws-partitions (1.1206.0) aws-sdk-accessanalyzer (1.84.0) aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-core (3.241.3) + aws-sdk-core (3.241.4) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -154,8 +154,8 @@ GEM aws-sdk-ecr (1.118.0) aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-iam (1.139.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-iam (1.140.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-kms (1.120.0) aws-sdk-core (~> 3, >= 3.241.3) From c7bb5962651eabb7520ebcfe2d50b72d13967390 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Jan 2026 21:56:33 +0000 Subject: [PATCH 19/63] Bump aws-sdk-ec2 from 1.590.0 to 1.591.0 Bumps [aws-sdk-ec2](https://github.com/aws/aws-sdk-ruby) from 1.590.0 to 1.591.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-ec2/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-ec2 dependency-version: 1.591.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e8cad7beb4..cad17ab4d2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,11 +136,11 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1205.0) + aws-partitions (1.1206.0) aws-sdk-accessanalyzer (1.84.0) aws-sdk-core (~> 3, >= 3.241.3) aws-sigv4 (~> 1.5) - aws-sdk-core (3.241.3) + aws-sdk-core (3.241.4) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -148,8 +148,8 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-ec2 (1.590.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-ec2 (1.591.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-ecr (1.118.0) aws-sdk-core (~> 3, >= 3.241.3) From 4aa1f706e5513f011fb5dae7c31abd691f38b809 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 07:20:26 +0000 Subject: [PATCH 20/63] Bump aws-sdk-accessanalyzer from 1.84.0 to 1.85.0 Bumps [aws-sdk-accessanalyzer](https://github.com/aws/aws-sdk-ruby) from 1.84.0 to 1.85.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-accessanalyzer/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-accessanalyzer dependency-version: 1.85.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 59a8e7a6f4..fd00077570 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -137,8 +137,8 @@ GEM attr_required (1.0.2) aws-eventstream (1.4.0) aws-partitions (1.1206.0) - aws-sdk-accessanalyzer (1.84.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-accessanalyzer (1.85.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-core (3.241.4) aws-eventstream (~> 1, >= 1.3.0) From 6e42319e895d5b7554745ee1db5c2f1e1a95b434 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 14 Jan 2026 10:29:45 +0000 Subject: [PATCH 21/63] Add `joins_teams_on_performed_ods_code` scope This adds a new scope to the `VaccinationRecord` model making it clearer how this is joining the vaccination record to the team. Jira-Issue: MAV-2987 --- .../concerns/contributes_to_patient_teams.rb | 15 ++------------- app/models/vaccination_record.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/app/models/concerns/contributes_to_patient_teams.rb b/app/models/concerns/contributes_to_patient_teams.rb index 4b55ae21c9..2ae138beb4 100644 --- a/app/models/concerns/contributes_to_patient_teams.rb +++ b/app/models/concerns/contributes_to_patient_teams.rb @@ -31,11 +31,9 @@ def contributing_subqueries }, vaccination_record_organisation: { patient_id_source: "vaccination_records.patient_id", - team_id_source: "tms.id", + team_id_source: "teams.id", contribution_scope: - joins(join_teams_to_vaccinations_via_organisation).where( - session_id: nil - ) + joins_teams_on_performed_ods_code.where(session_id: nil) }, vaccination_record_import: { patient_id_source: "vaccination_records.patient_id", @@ -296,15 +294,6 @@ def join_vaccination_records_to_organisation SQL end - def join_teams_to_vaccinations_via_organisation - <<-SQL - INNER JOIN organisations org - ON vaccination_records.performed_ods_code = org.ods_code - INNER JOIN teams tms - ON org.id = tms.organisation_id - SQL - end - def join_team_locations_to_school_moves <<-SQL INNER JOIN locations loc diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index 27674a118f..ad0c181871 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -134,6 +134,16 @@ class ActiveRecord_Relation < ActiveRecord::Relation after_update :recalculate_next_dose_delay_triage_date, if: :saved_change_to_performed_at? + scope :joins_organisation_on_performed_ods_code, -> { joins(<<-SQL) } + INNER JOIN organisations organisation + ON vaccination_records.performed_ods_code = organisation.ods_code + SQL + + scope :joins_teams_on_performed_ods_code, + -> { joins_organisation_on_performed_ods_code.joins(<<-SQL) } + INNER JOIN teams ON organisation.id = teams.organisation_id + SQL + scope :for_academic_year, ->(academic_year) do where(performed_at: academic_year.to_academic_year_date_range) From b0107c0f55569c9ff46761ce3e9549026b8e8009 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 14 Jan 2026 10:17:08 +0000 Subject: [PATCH 22/63] Add `PatientTeamUpdater` This adds a class which handles updating patient teams in one go. It works by fetching all the associated contributing models filtered either by a patient scope or a team scope, and building a complete picture of what the patient teams should look like before then bulk updating the records in the database. Jira-Issue: MAV-2987 --- app/lib/patient_team_updater.rb | 260 ++++++++++++++++++++++++++ app/models/patient_location.rb | 5 + spec/factories/vaccination_records.rb | 2 +- spec/lib/patient_team_updater_spec.rb | 223 ++++++++++++++++++++++ 4 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 app/lib/patient_team_updater.rb create mode 100644 spec/lib/patient_team_updater_spec.rb diff --git a/app/lib/patient_team_updater.rb b/app/lib/patient_team_updater.rb new file mode 100644 index 0000000000..24a36a1602 --- /dev/null +++ b/app/lib/patient_team_updater.rb @@ -0,0 +1,260 @@ +# frozen_string_literal: true + +class PatientTeamUpdater + def initialize(patient_scope: nil, team_scope: nil) + @patient_scope = patient_scope + @team_scope = team_scope + end + + def call + upsert_patient_teams! + delete_patient_teams_without_sources! + end + + def self.call(...) = new(...).call + + private_class_method :new + + private + + attr_reader :patient_scope, :team_scope + + def upsert_patient_teams! + patient_team_rows.in_groups_of(10_000, false) do |rows| + PatientTeam.import!( + %i[patient_id team_id sources], + rows, + on_duplicate_key_update: { + conflict_target: %i[patient_id team_id], + columns: %i[sources] + } + ) + end + end + + def delete_patient_teams_without_sources! + PatientTeam.missing_sources.delete_all + end + + def patient_team_rows + @patient_team_rows ||= + patient_team_sources.map do |(patient_id, team_id), sources| + [patient_id, team_id, sources] + end + end + + def patient_team_sources + @patient_team_sources ||= + sources.each_with_object( + existing_patient_team_pairs + ) do |(source, patient_team_pairs), hash| + patient_team_pairs.each do |patient_team_pair| + hash[patient_team_pair] ||= [] + hash[patient_team_pair] << PatientTeam.sources.fetch(source) + end + end + end + + def existing_patient_team_pairs + scope = merge_team_scope(merge_patient_scope(PatientTeam)) + + scope.pluck(:patient_id, :team_id).index_with { |_pair| [] } + end + + def sources + @sources ||= { + archive_reason: archive_reasons, + patient_location: patient_locations, + school_move_school: school_moves_by_school, + school_move_team: school_moves_by_team, + vaccination_record_import: vaccination_records_by_import, + vaccination_record_organisation: vaccination_records_by_organisation, + vaccination_record_session: vaccination_records_by_session + } + end + + def archive_reasons + scope = merge_team_scope(merge_patient_scope(ArchiveReason)) + + scope.pluck(:patient_id, :team_id) + end + + def patient_locations + # We define an alias here in case the `patient_scope` already includes a + # join on the `team_locations` table. + + scope = + merge_patient_scope( + joins_team_locations_alias_on_patient_locations(PatientLocation) + ) + + if is_team_scope_id_only? + scope = + scope.where(team_locations_alias: { team_id: team_scope.select(:id) }) + elsif team_scope + scope = joins_teams_on_team_locations_alias(scope).merge(team_scope) + end + + scope.pluck(:patient_id, :"team_locations_alias.team_id") + end + + def school_moves_by_school + # We define an alias here in case the `patient_scope` already includes a + # join on the `team_locations` table. + + scope = + merge_patient_scope( + joins_team_locations_alias_on_school_moves(SchoolMove) + ) + + if is_team_scope_id_only? + scope = + scope.where(team_locations_alias: { team_id: team_scope.select(:id) }) + elsif team_scope + scope = joins_teams_on_team_locations_alias(scope).merge(team_scope) + end + + scope.pluck(:patient_id, :"team_locations_alias.team_id") + end + + def school_moves_by_team + scope = + merge_team_scope(merge_patient_scope(SchoolMove.where.not(team_id: nil))) + + scope.pluck(:patient_id, :team_id) + end + + def vaccination_records_by_import + scope = merge_patient_scope(VaccinationRecord.joins(:immunisation_imports)) + + if is_team_scope_id_only? + scope = + scope.where(immunisation_imports: { team_id: team_scope.select(:id) }) + elsif team_scope + scope = scope.joins(immunisation_imports: :team).merge(team_scope) + end + + scope.pluck(:patient_id, :"immunisation_imports.team_id") + end + + def vaccination_records_by_organisation + scope = + merge_patient_scope( + VaccinationRecord.where( + session_id: nil + ).joins_teams_on_performed_ods_code + ) + + scope = scope.merge(team_scope) if team_scope + + scope.pluck(:patient_id, :"teams.id") + end + + def vaccination_records_by_session + # We define an alias here in case the `patient_scope` already includes a + # join on the `sessions` table or `team_locations` table. + + scope = + merge_patient_scope( + joins_team_locations_alias_on_vaccination_records(VaccinationRecord) + ) + + if is_team_scope_id_only? + scope = + scope.where(team_locations_alias: { team_id: team_scope.select(:id) }) + elsif team_scope + scope = joins_teams_on_team_locations_alias(scope).merge(team_scope) + end + + scope.pluck(:patient_id, :"team_locations_alias.team_id") + end + + # These are aliased joins that we need to perform in case the patient or + # team scopes already make reference to these tables. + + def joins_team_locations_alias_on_patient_locations(scope) + scope.joins(<<-SQL).references(:team_locations_alias) + INNER JOIN team_locations team_locations_alias + ON team_locations_alias.location_id = patient_locations.location_id + AND team_locations_alias.academic_year = patient_locations.academic_year + SQL + end + + def joins_team_locations_alias_on_school_moves(scope) + scope.joins(<<-SQL).references(:team_locations_alias) + INNER JOIN team_locations team_locations_alias + ON team_locations_alias.location_id = school_moves.school_id + AND team_locations_alias.academic_year = school_moves.academic_year + SQL + end + + def joins_team_locations_alias_on_vaccination_records(scope) + scope.joins(<<-SQL).references(:sessions_alias, :team_locations_alias) + INNER JOIN sessions sessions_alias + ON sessions_alias.id = vaccination_records.session_id + INNER JOIN team_locations team_locations_alias + ON team_locations_alias.id = sessions_alias.team_location_id + SQL + end + + def joins_teams_on_team_locations_alias(scope) + scope.joins("INNER JOIN teams ON teams.id = team_locations_alias.team_id") + end + + # The following code is necessary to support an optimisation where we're + # updating a specific team or patient by ID. + # + # These methods allow us to check if the scope where clause is only checking + # the ID of the patient or team, meaning we can optimise out the `JOIN` and + # filter the table directly. + # + # For example, without this optimisation, a scope of `Patient.where(id: 1)` + # would result in the following SQL query: + # + # SELECT patient_id, team_id FROM patient_teams + # JOIN patients ON patients.id = patient_teams.patient_id + # WHERE patients.id = 1 + # + # Whereas we know that the following SQL query is all we need in this case: + # + # SELECT patient_id, team_id FROM patient_teams + # WHERE patient_teams.patient_id = 1 + + def merge_patient_scope(scope) + if is_patient_scope_id_only? + scope.where(patient_id: patient_scope.select(:id)) + elsif patient_scope + scope.joins(:patient).merge(patient_scope) + else + scope + end + end + + def merge_team_scope(scope) + if is_team_scope_id_only? + scope.where(team_id: team_scope.select(:id)) + elsif team_scope + scope.joins(:team).merge(team_scope) + else + scope + end + end + + def is_patient_scope_id_only? + @is_patient_scope_id_only ||= is_id_only_scope?(patient_scope) + end + + def is_team_scope_id_only? + @is_team_scope_id_only ||= is_id_only_scope?(team_scope) + end + + def is_id_only_scope?(scope) + if scope.nil? || scope.joins_values.any? || + scope.left_outer_joins_values.any? + return false + end + + values = scope.where_values_hash + values.key?("id") && values.size == 1 + end +end diff --git a/app/models/patient_location.rb b/app/models/patient_location.rb index 7cea810816..f6e05f8ff9 100644 --- a/app/models/patient_location.rb +++ b/app/models/patient_location.rb @@ -82,6 +82,11 @@ class ActiveRecord_Relation < ActiveRecord::Relation AND team_locations.academic_year = patient_locations.academic_year SQL + scope :joins_teams, -> { references(:teams).joins(<<-SQL) } + INNER JOIN teams + ON teams.id = team_locations.team_id + SQL + scope :joins_sessions, -> { joins_team_locations.joins(<<-SQL) } INNER JOIN sessions ON sessions.team_location_id = team_locations.id diff --git a/spec/factories/vaccination_records.rb b/spec/factories/vaccination_records.rb index b3f7ef4e37..690b08cc77 100644 --- a/spec/factories/vaccination_records.rb +++ b/spec/factories/vaccination_records.rb @@ -85,7 +85,7 @@ programme { Programme.sample } - performed_ods_code { team.organisation.ods_code } + performed_ods_code { team&.organisation&.ods_code } patient do association :patient, diff --git a/spec/lib/patient_team_updater_spec.rb b/spec/lib/patient_team_updater_spec.rb new file mode 100644 index 0000000000..ec0af060e1 --- /dev/null +++ b/spec/lib/patient_team_updater_spec.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +describe PatientTeamUpdater do + shared_examples "updates" do + context "with an archive reason" do + before do + create(:archive_reason, :imported_in_error, patient:, team:) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly("archive_reason") + end + end + + context "with a patient location" do + before do + create(:patient_location, patient:, session: create(:session, team:)) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly("patient_location") + end + end + + context "with a school move by school" do + before do + create( + :school_move, + :to_school, + patient:, + school: create(:school, team:) + ) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly( + "school_move_school" + ) + end + end + + context "with a school move by team" do + before do + create(:school_move, :to_home_educated, patient:, team:) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly("school_move_team") + end + end + + context "with a vaccination record by import" do + before do + create( + :vaccination_record, + patient:, + team: nil, + immunisation_imports: [create(:immunisation_import, team:)] + ) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly( + "vaccination_record_import" + ) + end + end + + context "with a vaccination record by organisation" do + before do + create(:vaccination_record, patient:, team:) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly( + "vaccination_record_organisation" + ) + end + end + + context "with a vaccination record by session" do + before do + create( + :vaccination_record, + patient:, + team: nil, + session: create(:session, team:) + ) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly( + "vaccination_record_session" + ) + end + end + + context "with multiple sources" do + before do + create(:archive_reason, :imported_in_error, patient:, team:) + create(:patient_location, patient:, session: create(:session, team:)) + + # We need to do this because callbacks create them automatically. + PatientTeam.delete_all + end + + it "adds the patient to the team" do + expect(patient.teams).to be_empty + expect { call }.to change(PatientTeam, :count).by(1) + expect(PatientTeam.last.sources).to contain_exactly( + "archive_reason", + "patient_location" + ) + end + end + + context "with a previous source that's no longer applicable" do + before do + create(:archive_reason, :imported_in_error, patient:, team:) + PatientTeam.find_by!(patient:, team:).update!( + sources: %w[patient_location] + ) + end + + it "adds the patient to the team" do + expect { call }.not_to change(PatientTeam, :count) + expect(PatientTeam.last.sources).to contain_exactly("archive_reason") + end + end + + context "when previously part of a team" do + before do + PatientTeam.create!(patient:, team:, sources: %w[patient_location]) + end + + it "removes the patient from the team" do + expect { call }.to change(PatientTeam, :count).by(-1) + end + end + end + + context "without scopes" do + subject(:call) { described_class.call } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + include_examples "updates" + end + + context "with a patient scope" do + subject(:call) { described_class.call(patient_scope:) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + context "and filtering by ID" do + let(:patient_scope) { Patient.where(id: patient.id) } + + include_examples "updates" + end + + context "and filtering by a different column" do + let(:patient_scope) { Patient.where(family_name: patient.family_name) } + + include_examples "updates" + end + end + + context "with a team scope" do + subject(:call) { described_class.call(team_scope:) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + context "and filtering by ID" do + let(:team_scope) { Team.where(id: team.id) } + + include_examples "updates" + end + + context "and filtering by a different column" do + let(:team_scope) { Team.where(workgroup: team.workgroup) } + + include_examples "updates" + end + end +end From 62b4586cca3c18a842212686a87ce6ca1252cdb7 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 11:30:18 +0000 Subject: [PATCH 23/63] Add `patient_team:update` tasks This adds a number of Rake tasks that can be used to update the patient-team associations for a specific patient, team or for all patients and teams. Jira-Issue: MAV-2987 --- lib/tasks/patient_team.rake | 42 ++++++++++++++++++++++++++++++++++++ lib/tasks/patient_teams.rake | 23 -------------------- lib/tasks/status.rake | 2 +- 3 files changed, 43 insertions(+), 24 deletions(-) create mode 100644 lib/tasks/patient_team.rake delete mode 100644 lib/tasks/patient_teams.rake diff --git a/lib/tasks/patient_team.rake b/lib/tasks/patient_team.rake new file mode 100644 index 0000000000..c1807f2cad --- /dev/null +++ b/lib/tasks/patient_team.rake @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +namespace :patient_team do + namespace :update do + desc "Update the patient-teams of all the patients and teams." + task all: :environment do |_, _args| + PatientTeamUpdater.call + end + + desc "Update the patient-teams of a specific patient by ID." + task :patient, [:id] => :environment do |_, args| + patient_scope = Patient.where(id: args[:id]) + PatientTeamUpdater.call(patient_scope:) + end + + desc "Update the patient-teams of a specific team by workgroup." + task :team, [:workgroup] => :environment do |_, args| + team_scope = Team.where(workgroup: args[:workgroup]) + PatientTeamUpdater.call(team_scope:) + end + end + + desc "Sync patient teams relationships" + task sync: :environment do + puts "Starting patient teams sync..." + + models = [PatientLocation, SchoolMove, ArchiveReason, VaccinationRecord] + + models.each do |model| + puts "Processing #{model.name}..." + begin + model.all.insert_patient_teams_relationships + puts "✓ Successfully synced #{model.name} with patient teams." + rescue StandardError => e + puts "✗ Error syncing #{model.name}: #{e.message}" + raise e + end + end + + puts "Patient teams sync completed successfully!" + end +end diff --git a/lib/tasks/patient_teams.rake b/lib/tasks/patient_teams.rake deleted file mode 100644 index 32c4552285..0000000000 --- a/lib/tasks/patient_teams.rake +++ /dev/null @@ -1,23 +0,0 @@ -# frozen_string_literal: true - -namespace :patient_teams do - desc "Sync patient teams relationships" - task sync: :environment do - puts "Starting patient teams sync..." - - models = [PatientLocation, SchoolMove, ArchiveReason, VaccinationRecord] - - models.each do |model| - puts "Processing #{model.name}..." - begin - model.all.insert_patient_teams_relationships - puts "✓ Successfully synced #{model.name} with patient teams." - rescue StandardError => e - puts "✗ Error syncing #{model.name}: #{e.message}" - raise e - end - end - - puts "Patient teams sync completed successfully!" - end -end diff --git a/lib/tasks/status.rake b/lib/tasks/status.rake index e4f7cd4447..7c4ec69468 100644 --- a/lib/tasks/status.rake +++ b/lib/tasks/status.rake @@ -7,7 +7,7 @@ namespace :status do StatusUpdater.call end - desc "Update the statuses of a sessions the patient is in." + desc "Update the statuses of a specific patient by ID." task :patient, [:id] => :environment do |_, args| patient = Patient.find(args[:id]) StatusUpdater.call(patient:) From 7c5b9d7f35a5b602919bcdab62046b272ededeab Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 08:54:16 +0000 Subject: [PATCH 24/63] Install `sidekiq-unique-jobs` This adds the Gem so we can use it in the patient team updater jobs to ensure that we only ever try to update the patient teams for a particular patient or team at any one time. Jira-Issue: MAV-2987 --- Gemfile | 1 + Gemfile.lock | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/Gemfile b/Gemfile index 4aff768d86..03b2d36048 100644 --- a/Gemfile +++ b/Gemfile @@ -69,6 +69,7 @@ gem "sentry-sidekiq" gem "sidekiq" gem "sidekiq-scheduler" gem "sidekiq-throttled" +gem "sidekiq-unique-jobs" gem "splunk-sdk-ruby" gem "table_tennis" gem "tzinfo-data", platforms: %i[jruby windows] diff --git a/Gemfile.lock b/Gemfile.lock index fd00077570..a304f79358 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -678,6 +678,10 @@ GEM concurrent-ruby (>= 1.2.0) redis-prescription (~> 2.2) sidekiq (>= 8.0) + sidekiq-unique-jobs (8.0.13) + concurrent-ruby (~> 1.0, >= 1.0.5) + sidekiq (>= 7.0.0, < 9.0.0) + thor (>= 1.0, < 3.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -890,6 +894,7 @@ DEPENDENCIES sidekiq sidekiq-scheduler sidekiq-throttled + sidekiq-unique-jobs simplecov solargraph solargraph-rails From 5e9a32c10ae50ab1717750c39645c7170f0bd34c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 10:13:27 +0000 Subject: [PATCH 25/63] Configure `sidekiq-unique-jobs` This adds some configuration for Sidekiq Unique Jobs to ensure that we can use it in jobs. Jira-Issue: MAV-2987 --- config/initializers/sidekiq.rb | 24 +++++++++++++++++++++++- config/routes.rb | 1 + 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 04e9bdb693..1a2facfcb1 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "sidekiq/throttled" +require "sidekiq-unique-jobs" redis_config = { url: ENV["SIDEKIQ_REDIS_URL"] || ENV["REDIS_URL"] } @@ -11,25 +12,46 @@ Sidekiq.configure_server do |config| config.redis = redis_config + + config.client_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Client + end + + config.server_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Server + end + + SidekiqUniqueJobs::Server.configure(config) + if ENV["EXPORT_SIDEKIQ_METRICS"] == "true" require "prometheus_exporter/instrumentation" + config.server_middleware do |chain| chain.add PrometheusExporter::Instrumentation::Sidekiq end + config.death_handlers << PrometheusExporter::Instrumentation::Sidekiq.death_handler + config.on :startup do PrometheusExporter::Instrumentation::Process.start type: "sidekiq" PrometheusExporter::Instrumentation::SidekiqProcess.start PrometheusExporter::Instrumentation::SidekiqQueue.start PrometheusExporter::Instrumentation::SidekiqStats.start end + at_exit do PrometheusExporter::Client.default.stop(wait_timeout_seconds: 10) end end end -Sidekiq.configure_client { |config| config.redis = redis_config } +Sidekiq.configure_client do |config| + config.redis = redis_config + + config.client_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Client + end +end Sidekiq::Throttled.configure do |config| config.cooldown_period = 1.0 diff --git a/config/routes.rb b/config/routes.rb index 3e390ff3e6..eb79d97e3f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -3,6 +3,7 @@ require "sidekiq/web" require "sidekiq-scheduler/web" require "sidekiq/throttled/web" +require "sidekiq_unique_jobs/web" Rails.application.routes.draw do # Redirect www subdomain to root in production envs From 2538b60dae9b8cec3f7c21d51236ad7c292e35f9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 10:10:28 +0000 Subject: [PATCH 26/63] Add `PatientTeamUpdaterJob` This adds a new job which we can use the trigger the patient team updater and run it asyncronously via Sidekiq. The job is configured to run with an "until executed" lock, meaning that any individual patient or team won't be updated in parallel. Jira-Issue: MAV-2987 --- app/jobs/patient_team_updater_job.rb | 13 +++++ spec/jobs/patient_team_updater_job_spec.rb | 60 ++++++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 app/jobs/patient_team_updater_job.rb create mode 100644 spec/jobs/patient_team_updater_job_spec.rb diff --git a/app/jobs/patient_team_updater_job.rb b/app/jobs/patient_team_updater_job.rb new file mode 100644 index 0000000000..2c3df4405d --- /dev/null +++ b/app/jobs/patient_team_updater_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PatientTeamUpdaterJob + include Sidekiq::Job + + sidekiq_options queue: :cache, lock: :until_executed + + def perform(patient_id = nil, team_id = nil) + patient_scope = (patient_id ? Patient.where(id: patient_id) : nil) + team_scope = (team_id ? Team.where(id: team_id) : nil) + PatientTeamUpdater.call(patient_scope:, team_scope:) + end +end diff --git a/spec/jobs/patient_team_updater_job_spec.rb b/spec/jobs/patient_team_updater_job_spec.rb new file mode 100644 index 0000000000..b6c631cfd8 --- /dev/null +++ b/spec/jobs/patient_team_updater_job_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +describe PatientTeamUpdaterJob do + describe "#perform" do + context "with no arguments" do + subject(:perform) { described_class.new.perform } + + it "calls the updater with the appropriate scopes" do + expect(Patient).not_to receive(:all) + expect(Team).not_to receive(:all) + expect(PatientTeamUpdater).to receive(:call) + + perform + end + end + + context "with a patient" do + subject(:perform) { described_class.new.perform(patient.id) } + + let(:patient) { create(:patient) } + + it "calls the updater with the appropriate scopes" do + expect(Patient).to receive(:where).with(id: patient.id) + expect(Team).not_to receive(:all) + expect(PatientTeamUpdater).to receive(:call) + + perform + end + end + + context "with a team" do + subject(:perform) { described_class.new.perform(nil, team.id) } + + let(:team) { create(:team) } + + it "calls the updater with the appropriate scopes" do + expect(Patient).not_to receive(:all) + expect(Team).to receive(:where).with(id: team.id) + expect(PatientTeamUpdater).to receive(:call) + + perform + end + end + + context "with a patient and a team" do + subject(:perform) { described_class.new.perform(patient.id, team.id) } + + let(:patient) { create(:patient) } + let(:team) { create(:team) } + + it "calls the updater with the appropriate scopes" do + expect(Patient).to receive(:where).with(id: patient.id) + expect(Team).to receive(:where).with(id: team.id) + expect(PatientTeamUpdater).to receive(:call) + + perform + end + end + end +end From f979b916585962f6d1f9dd11cf793b167769fff9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 12:13:23 +0000 Subject: [PATCH 27/63] Replace usage of `SyncPatientTeamJob` This replaces all usage of `SyncPatientTeamJob` with a call to the `PatientTeamUpdater`, allowing us to remove the job and simplify the code. Jira-Issue: MAV-2987 --- app/jobs/commit_import_job.rb | 9 +++--- app/jobs/commit_patient_changesets_job.rb | 6 ++-- app/jobs/sync_patient_team_job.rb | 11 ------- app/lib/clinic_patient_locations_factory.rb | 8 +++-- app/lib/generate/vaccination_records.rb | 10 ++++-- app/models/class_import.rb | 1 - app/models/immunisation_import.rb | 32 +++---------------- app/models/patient.rb | 10 +++--- app/models/school_move.rb | 9 +++--- db/seeds.rb | 14 ++++---- .../api/testing/teams_controller_spec.rb | 16 +++++----- spec/features/import_class_lists_move_spec.rb | 3 +- spec/features/manage_children_spec.rb | 1 - spec/support/imports_helper.rb | 1 - 14 files changed, 50 insertions(+), 81 deletions(-) delete mode 100644 app/jobs/sync_patient_team_job.rb diff --git a/app/jobs/commit_import_job.rb b/app/jobs/commit_import_job.rb index e2ba36e6fa..4cb83c786a 100644 --- a/app/jobs/commit_import_job.rb +++ b/app/jobs/commit_import_job.rb @@ -28,7 +28,6 @@ def perform(import_global_id) end counts = import.count_columns.index_with(0) - imported_school_move_ids = [] ActiveRecord::Base.transaction do changesets = @@ -44,11 +43,12 @@ def perform(import_global_id) increment_column_counts!(import, counts, changesets) import_patients_and_parents(changesets, import) - - imported_school_move_ids |= import_school_moves(changesets, import) - + import_school_moves(changesets, import) import_pds_search_results(changesets, import) end + + PatientTeamUpdater.call(patient_scope: import.patients) + import.postprocess_rows! reset_counts(import) @@ -59,7 +59,6 @@ def perform(import_global_id) **counts ) end - SyncPatientTeamJob.perform_later(SchoolMove, imported_school_move_ids) import.post_commit! end end diff --git a/app/jobs/commit_patient_changesets_job.rb b/app/jobs/commit_patient_changesets_job.rb index f5489d6053..e8d5e01360 100644 --- a/app/jobs/commit_patient_changesets_job.rb +++ b/app/jobs/commit_patient_changesets_job.rb @@ -22,7 +22,6 @@ def perform(patient_changeset_ids) changesets = PatientChangeset.includes(:patient).where(id: patient_changeset_ids) import = changesets.first.import - imported_school_move_ids = [] counts = import.count_columns.index_with { |col| import.public_send(col) || 0 } @@ -36,17 +35,18 @@ def perform(patient_changeset_ids) if to_process.any? increment_column_counts!(import, counts, to_process) import_patients_and_parents(to_process, import) - imported_school_move_ids = import_school_moves(to_process, import) + import_school_moves(to_process, import) import_pds_search_results(to_process, import) to_process.each(&:processed!) end + + PatientTeamUpdater.call(patient_scope: import.patients) end if finished_committing_changesets?(import) run_post_commit_tasks(import, counts) end - SyncPatientTeamJob.perform_later(SchoolMove, imported_school_move_ids) import.post_commit! end diff --git a/app/jobs/sync_patient_team_job.rb b/app/jobs/sync_patient_team_job.rb deleted file mode 100644 index cdc144e934..0000000000 --- a/app/jobs/sync_patient_team_job.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -class SyncPatientTeamJob < ApplicationJob - queue_as :cache - - def perform(model_class, id_array) - return if id_array.blank? - - model_class.all.sync_patient_teams_table_on_patient_ids(id_array) - end -end diff --git a/app/lib/clinic_patient_locations_factory.rb b/app/lib/clinic_patient_locations_factory.rb index 60565ba693..2c277c7b27 100644 --- a/app/lib/clinic_patient_locations_factory.rb +++ b/app/lib/clinic_patient_locations_factory.rb @@ -6,13 +6,17 @@ def initialize(school_session:) end def create_patient_locations! - imported_ids = + ActiveRecord::Base.transaction do PatientLocation.import!( patient_locations_to_create, on_duplicate_key_ignore: true ).ids - SyncPatientTeamJob.perform_later(PatientLocation, imported_ids) + PatientTeamUpdater.call( + patient_scope: patients_in_school, + team_scope: Team.where(id: team.id) + ) + end end def patient_locations_to_create diff --git a/app/lib/generate/vaccination_records.rb b/app/lib/generate/vaccination_records.rb index abc3f37461..63f61ccf49 100644 --- a/app/lib/generate/vaccination_records.rb +++ b/app/lib/generate/vaccination_records.rb @@ -45,10 +45,14 @@ def create_vaccinations end AttendanceRecord.import!(attendances) - imported_ids = VaccinationRecord.import!(vaccinations).ids - SyncPatientTeamJob.perform_later(VaccinationRecord, imported_ids) + VaccinationRecord.import!(vaccinations) - StatusUpdater.call(patient: vaccinations.map(&:patient)) + patients = vaccinations.map(&:patient) + + PatientTeamUpdater.call( + patient_scope: Patient.where(id: patients.map(&:id)) + ) + StatusUpdater.call(patient: patients) end def check_sessions_have_enough_patients diff --git a/app/models/class_import.rb b/app/models/class_import.rb index ebd8349acb..82c61e50d0 100644 --- a/app/models/class_import.rb +++ b/app/models/class_import.rb @@ -107,7 +107,6 @@ def postprocess_rows! end def post_commit! - SyncPatientTeamJob.perform_later(SchoolMove, @imported_school_move_ids) end def patients_to_create_moves_for(patients_in_import) diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 6a36780c14..0217d67e0a 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -83,10 +83,6 @@ def process_row(row) @patient_locations_batch ||= Set.new @archive_reasons_batch ||= Set.new - @imported_vaccination_record_ids ||= [] - @imported_patient_location_ids ||= [] - @imported_archive_reason_ids ||= [] - @vaccination_records_batch.add(vaccination_record) if (batch = vaccination_record.batch) @batches_batch.add(batch) @@ -134,24 +130,15 @@ def bulk_import(rows: 100) patient_locations = @patient_locations_batch.to_a archive_reasons = @archive_reasons_batch.to_a - @imported_vaccination_record_ids |= - VaccinationRecord.import( - vaccination_records, - on_duplicate_key_update: :all - ).ids + VaccinationRecord.import(vaccination_records, on_duplicate_key_update: :all) vaccination_records.each do |vaccination_record| AlreadyHadNotificationSender.call(vaccination_record:) end - @imported_patient_location_ids |= - PatientLocation.import( - patient_locations, - on_duplicate_key_ignore: :all - ).ids + PatientLocation.import(patient_locations, on_duplicate_key_ignore: :all) - @imported_archive_reason_ids |= - ArchiveReason.import(archive_reasons, on_duplicate_key_ignore: :all).ids + ArchiveReason.import(archive_reasons, on_duplicate_key_ignore: :all) [ [:vaccination_records, vaccination_records], @@ -190,22 +177,11 @@ def postprocess_rows! NextDoseTriageFactory.call(vaccination_record:) end + PatientTeamUpdater.call(patient_scope: patients) StatusUpdater.call(patient: patients) end def post_commit! - SyncPatientTeamJob.perform_later( - VaccinationRecord, - @imported_vaccination_record_ids - ) - SyncPatientTeamJob.perform_later( - PatientLocation, - @imported_patient_location_ids - ) - SyncPatientTeamJob.perform_later( - ArchiveReason, - @imported_archive_reason_ids - ) vaccination_records.sync_all_to_nhs_immunisations_api end end diff --git a/app/models/patient.rb b/app/models/patient.rb index 5cec5613ec..1450141321 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -654,13 +654,12 @@ def update_from_pds!(pds_patient) raise NHSNumberMismatch end - archived_ids = [] ActiveRecord::Base.transaction do self.date_of_death = pds_patient.date_of_death if date_of_death_changed? if date_of_death.present? - archived_ids = archive_due_to_deceased! + archive_due_to_deceased! clear_pending_sessions! end @@ -691,8 +690,6 @@ def update_from_pds!(pds_patient) save! end - - SyncPatientTeamJob.perform_later(ArchiveReason, archived_ids) end def invalidate! @@ -823,6 +820,11 @@ def archive_due_to_deceased! columns: %i[type] } ).ids + + PatientTeamUpdater.call( + patient_scope: Patient.where(id:), + team_scope: Team.where(id: team_ids) + ) end def fhir_mapper = @fhir_mapper ||= FHIRMapper::Patient.new(self) diff --git a/app/models/school_move.rb b/app/models/school_move.rb index 1a153156da..a598ac3b3c 100644 --- a/app/models/school_move.rb +++ b/app/models/school_move.rb @@ -85,18 +85,16 @@ def assign_from(school:, home_educated:, team:) def confirm!(user: nil) old_teams = patient.school.teams if from_another_team? - imported_archive_reason_ids = [] - ActiveRecord::Base.transaction do update_patient! - imported_archive_reason_ids = update_archive_reasons!(user:) + update_archive_reasons!(user:) update_sessions! + log_entry = create_log_entry!(user:) create_important_notice!(old_teams, log_entry) if old_teams + destroy! if persisted? end - - SyncPatientTeamJob.perform_later(ArchiveReason, imported_archive_reason_ids) end def ignore! @@ -145,6 +143,7 @@ def update_sessions! PatientLocation.find_or_create_by!(patient:, location:, academic_year:) + PatientTeamUpdater.call(patient_scope: Patient.where(id: patient.id)) StatusUpdater.call(patient:) end diff --git a/db/seeds.rb b/db/seeds.rb index ccb00de9c5..93662c0f20 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -205,12 +205,10 @@ def setup_clinic(team) ) end - imported_ids = - PatientLocation.import( - new_patient_location_records, - on_duplicate_key_ignore: :all - ).ids - SyncPatientTeamJob.perform_now(PatientLocation, imported_ids) + PatientLocation.import( + new_patient_location_records, + on_duplicate_key_ignore: :all + ).ids end def create_patients(team) @@ -406,5 +404,7 @@ def create_upload_patients_and_vaccination_records(user) create_imports(user, team) create_school_moves(team) -Rake::Task["status:update:all"].execute +PatientTeamUpdater.call +StatusUpdater.call + Rake::Task["smoke:seed"].execute diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb index 928f3be4c4..4fa70b8cd9 100644 --- a/spec/controllers/api/testing/teams_controller_spec.rb +++ b/spec/controllers/api/testing/teams_controller_spec.rb @@ -90,11 +90,11 @@ .and(change(Session, :count).by(-1)) .and(change(CohortImport, :count).by(-1)) .and(change(ImmunisationImport, :count).by(-1)) - .and(change(NotifyLogEntry, :count).by(-3)) - .and(change(Parent, :count).by(-4)) - .and(change(Patient, :count).by(-3)) + .and(change(NotifyLogEntry, :count).by(-13)) + .and(change(Parent, :count).by(-14)) + .and(change(Patient, :count).by(-13)) .and(change(PatientLocation, :count).by(-3)) - .and(change(VaccinationRecord, :count).by(-9)) + .and(change(VaccinationRecord, :count).by(-11)) ) end @@ -113,11 +113,11 @@ .and(change(Session, :count).by(-1)) .and(change(CohortImport, :count).by(-1)) .and(change(ImmunisationImport, :count).by(-1)) - .and(change(NotifyLogEntry, :count).by(-3)) - .and(change(Parent, :count).by(-4)) - .and(change(Patient, :count).by(-3)) + .and(change(NotifyLogEntry, :count).by(-13)) + .and(change(Parent, :count).by(-14)) + .and(change(Patient, :count).by(-13)) .and(change(PatientLocation, :count).by(-3)) - .and(change(VaccinationRecord, :count).by(-9)) + .and(change(VaccinationRecord, :count).by(-11)) ) end diff --git a/spec/features/import_class_lists_move_spec.rb b/spec/features/import_class_lists_move_spec.rb index 5664f7b6d9..ac0d648c97 100644 --- a/spec/features/import_class_lists_move_spec.rb +++ b/spec/features/import_class_lists_move_spec.rb @@ -340,8 +340,7 @@ def then_i_should_see_a_notice_flash def and_the_patient_should_be_in_the_right_teams patient = Patient.select { it.archive_reasons.count > 0 }.sole - expect(patient.teams.count).to eq(1) - expect(patient.teams.pluck(:id)).to include(@second_team.id) + expect(patient.teams).to contain_exactly(@team, @second_team) expect(patient.archive_reasons.sole.team_id).to eq(@team.id) expect(patient.archive_reasons.sole.type).to eq("moved_out_of_area") end diff --git a/spec/features/manage_children_spec.rb b/spec/features/manage_children_spec.rb index dc45623498..753f4ecb47 100644 --- a/spec/features/manage_children_spec.rb +++ b/spec/features/manage_children_spec.rb @@ -718,7 +718,6 @@ def when_the_patient_is_added_to_the_new_team expect(@patient_all_notices.teams).to include(@new_team) expect(@patient_all_notices.teams).to include(@team) - perform_enqueued_jobs_while_exists(only: SyncPatientTeamJob) perform_enqueued_jobs_while_exists(only: ImportantNoticeGeneratorJob) end diff --git a/spec/support/imports_helper.rb b/spec/support/imports_helper.rb index 7e56115e95..960c9d58b5 100644 --- a/spec/support/imports_helper.rb +++ b/spec/support/imports_helper.rb @@ -24,7 +24,6 @@ def wait_for_import_to_complete_until_review(import_class) perform_enqueued_jobs_while_exists(only: ProcessPatientChangesetJob) perform_enqueued_jobs_while_exists(only: ReviewPatientChangesetJob) perform_enqueued_jobs(only: ReviewClassImportSchoolMoveJob) - perform_enqueued_jobs(only: SyncPatientTeamJob) if Flipper.enabled?(:import_review_screen) click_on_most_recent_import(import_class) From 09830c2d3e3af8f4a46195e703aaf68d1e721f55 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 11:24:55 +0000 Subject: [PATCH 28/63] Remove `update_all_and_sync_patient_teams` This removes this method from the `ContributedToPatientTeams` relation and replaces it usage with an explicit call to `PatientTeamUpdater`. Jira-Issue: MAV-2987 --- app/lib/mavis_cli/schools/move_patients.rb | 17 +-- .../concerns/contributes_to_patient_teams.rb | 103 ------------------ docs/ops-tasks.md | 9 +- spec/models/patient_location_spec.rb | 33 +----- 4 files changed, 19 insertions(+), 143 deletions(-) diff --git a/app/lib/mavis_cli/schools/move_patients.rb b/app/lib/mavis_cli/schools/move_patients.rb index 569654ed70..f302978c45 100644 --- a/app/lib/mavis_cli/schools/move_patients.rb +++ b/app/lib/mavis_cli/schools/move_patients.rb @@ -35,13 +35,14 @@ def call(source_urn:, target_urn:) end team_id = - old_loc.team_locations.ordered.find_by!(academic_year:).team_id + old_loc.team_locations.ordered.where(academic_year:).sole.team_id old_team_location = old_loc .team_locations .includes(:team) .find_by!(academic_year:, team_id:) + new_team_location = new_loc .team_locations @@ -75,25 +76,23 @@ def call(source_urn:, target_urn:) ) end - Session.where( - team_location_id: old_team_location.id - ).update_all_and_sync_patient_teams( + Session.where(team_location_id: old_team_location.id).update_all( team_location_id: new_team_location.id ) Patient.where(school_id: old_loc.id).update_all(school_id: new_loc.id) PatientLocation.where( academic_year:, location_id: old_loc.id - ).update_all_and_sync_patient_teams(location_id: new_loc.id) + ).update_all(location_id: new_loc.id) ConsentForm.where(team_location_id: old_team_location.id).update_all( team_location_id: new_team_location.id ) ConsentForm.where(school_id: old_loc.id).update_all( school_id: new_loc.id ) - SchoolMove.where( - school_id: old_loc.id - ).update_all_and_sync_patient_teams(school_id: new_loc.id) + SchoolMove.where(school_id: old_loc.id).update_all( + school_id: new_loc.id + ) Patient .where(school_id: new_loc.id) .find_each do |patient| @@ -101,6 +100,8 @@ def call(source_urn:, target_urn:) end old_team_location.destroy! + + PatientTeamUpdater.call(team_scope: Team.where(id: team_id)) end end end diff --git a/app/models/concerns/contributes_to_patient_teams.rb b/app/models/concerns/contributes_to_patient_teams.rb index 2ae138beb4..6789114d43 100644 --- a/app/models/concerns/contributes_to_patient_teams.rb +++ b/app/models/concerns/contributes_to_patient_teams.rb @@ -108,109 +108,6 @@ def contributing_subqueries end end - def update_all_and_sync_patient_teams(updates) - transaction do - contributing_subqueries.each do |source, subquery| - affected_row_ids = connection.quote_table_name("temp_table_#{source}") - source_key = connection.quote(PatientTeam.sources.fetch(source.to_s)) - patient_id_source = - connection.quote_string(subquery[:patient_id_source]) - team_id_source = connection.quote_string(subquery[:team_id_source]) - - connection.execute <<-SQL - CREATE TEMPORARY TABLE #{affected_row_ids} ( - id bigint, - patient_id bigint, - team_id bigint - ) ON COMMIT DROP; - SQL - - source_table_affected_rows_all = - all.select( - "#{table_name}.id as id", - "NULL as patient_id", - "NULL as team_id" - ).to_sql - - connection.execute <<-SQL - INSERT INTO #{affected_row_ids} (#{source_table_affected_rows_all}); - SQL - - # We need to do this because sometimes the `contribution_scope` results in - # no results, if for example the patient or team ID comes from a join. - source_table_affected_rows_with_patient_team = - all - .contributing_subqueries - .fetch(source) - .fetch(:contribution_scope) - .select( - "#{table_name}.id as id", - "#{patient_id_source} as patient_id", - "#{team_id_source} as team_id" - ) - .to_sql - - connection.execute <<-SQL - INSERT INTO #{affected_row_ids} (#{source_table_affected_rows_with_patient_team}); - SQL - - patient_team_relationships_to_remove = - subquery[:contribution_scope] - .select( - "#{patient_id_source} as patient_id", - "#{team_id_source} as team_id" - ) - .reorder("patient_id") - .distinct - .to_sql - - connection.execute <<-SQL - UPDATE patient_teams pt - SET sources = array_remove(sources, #{source_key}) - FROM (#{patient_team_relationships_to_remove}) as pre_changed - WHERE pt.patient_id = pre_changed.patient_id AND pt.team_id = pre_changed.team_id; - SQL - end - - update_all(updates) - - klass.all.contributing_subqueries.each do |source, subquery| - affected_row_ids = connection.quote_table_name("temp_table_#{source}") - source_key = connection.quote(PatientTeam.sources.fetch(source.to_s)) - patient_id_source = - connection.quote_string(subquery[:patient_id_source]) - team_id_source = connection.quote_string(subquery[:team_id_source]) - - patient_team_relationships_to_insert = - subquery[:contribution_scope] - .select( - "#{patient_id_source} as patient_id", - "#{team_id_source} as team_id" - ) - .joins( - "INNER JOIN #{affected_row_ids} ON #{affected_row_ids}.id = #{table_name}.id " \ - "OR (#{affected_row_ids}.patient_id = #{patient_id_source} " \ - "AND #{affected_row_ids}.team_id = #{team_id_source})" - ) - .reorder("patient_id") - .distinct - .to_sql - - connection.execute <<-SQL - INSERT INTO patient_teams (patient_id, team_id, sources) - SELECT post_changed.patient_id, post_changed.team_id, ARRAY[#{source_key}] - FROM (#{patient_team_relationships_to_insert}) as post_changed - ON CONFLICT (team_id, patient_id) DO UPDATE - SET sources = array_append(array_remove(patient_teams.sources,#{source_key}),#{source_key}) - SQL - - connection.execute("DROP TABLE IF EXISTS #{affected_row_ids}") - - PatientTeam.missing_sources.delete_all - end - end - end - def insert_patient_teams_relationships transaction { add_patient_team_relationships } end diff --git a/docs/ops-tasks.md b/docs/ops-tasks.md index aafce42c53..fa03492ea3 100644 --- a/docs/ops-tasks.md +++ b/docs/ops-tasks.md @@ -16,14 +16,17 @@ session.patients.count # get the number of patients session.patient_locations.all?(&:safe_to_destroy?) # update all the patients to unknown school -session.patients.update_all_and_sync_patient_teams( +session.patients.update_all( cohort_id: nil, home_educated: false, school_id: nil ) -# removes all patients from the session -session.patient_locations.destroy_all_with_patient_team_sync +# remove all patients from the session +session.patient_locations.destroy_all + +# update all the patient-team associations +PatientTeamUpdater.call(team_scope: Team.where(id: team.id)) ``` ## Add a patient from community clinic to school session diff --git a/spec/models/patient_location_spec.rb b/spec/models/patient_location_spec.rb index 9f907f314d..7785e2760f 100644 --- a/spec/models/patient_location_spec.rb +++ b/spec/models/patient_location_spec.rb @@ -59,11 +59,12 @@ expect(PatientTeam.count).to eq(0) expect { - described_class.where( - id: patient_location.id - ).update_all_and_sync_patient_teams( + described_class.where(id: patient_location.id).update_all( academic_year: session.academic_year ) + PatientTeamUpdater.call( + patient_scope: Patient.where(id: patient_location.patient_id) + ) }.to change(PatientTeam, :count).by(1) patient_team = PatientTeam.last @@ -153,32 +154,6 @@ end end - describe "#update_all_and_sync_patient_teams" do - context "when a patient has two patient locations that contribute" do - let(:patient) { create(:patient) } - let(:team) { create(:team) } - let(:session_a) { create(:session, team:) } - let(:session_b) { create(:session, team:) } - let!(:patient_location_a) do - create(:patient_location, patient:, session: session_a) - end - let!(:patient_location_b) do - create(:patient_location, patient:, session: session_b) - end - - it "keeps the patient team relationship when one is updated" do - expect(patient.reload.teams).to contain_exactly(team) - - described_class.where( - id: patient_location_a - ).update_all_and_sync_patient_teams(location_id: create(:school).id) - expect(patient_location_b).not_to be_nil - - expect(patient.reload.teams).to contain_exactly(team) - end - end - end - describe "#safe_to_destroy?" do subject { patient_location.safe_to_destroy? } From 04846dad69db793942692cc81c66133d826df931 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 12:16:27 +0000 Subject: [PATCH 29/63] Remove `sync_patient_teams_table_on_patient_ids` This is no longer being used so it can be safely removed. Jira-Issue: MAV-2987 --- .../concerns/contributes_to_patient_teams.rb | 37 ------------------- spec/models/vaccination_record_spec.rb | 29 --------------- 2 files changed, 66 deletions(-) diff --git a/app/models/concerns/contributes_to_patient_teams.rb b/app/models/concerns/contributes_to_patient_teams.rb index 6789114d43..479262f261 100644 --- a/app/models/concerns/contributes_to_patient_teams.rb +++ b/app/models/concerns/contributes_to_patient_teams.rb @@ -112,43 +112,6 @@ def insert_patient_teams_relationships transaction { add_patient_team_relationships } end - def sync_patient_teams_table_on_patient_ids(pk_ids) - affected_patient_ids = [] - transaction do - contributing_subqueries.each do |key, subquery| - patient_id_source = - connection.quote_string(subquery[:patient_id_source]) - source_key = connection.quote(PatientTeam.sources.fetch(key.to_s)) - - affected_patient_ids |= - select("#{subquery[:patient_id_source]} as patient_id") - .where("#{table_name}.id = ANY(ARRAY[?]::bigint[])", pk_ids) - .distinct - .pluck(:patient_id) - - patient_relationships_to_remove = - select("#{patient_id_source} as patient_id") - .where("#{table_name}.id = ANY(ARRAY[?]::bigint[])", pk_ids) - .distinct - .to_sql - connection.execute <<-SQL - UPDATE patient_teams pt - SET sources = array_remove(sources, #{source_key}) - FROM (#{patient_relationships_to_remove}) as alias - WHERE pt.patient_id = alias.patient_id; - SQL - end - - all.add_patient_team_relationships(patient_ids: affected_patient_ids) - - PatientTeam.missing_sources.delete_all - end - - if affected_patient_ids.any? - ImportantNoticeGeneratorJob.perform_later(affected_patient_ids) - end - end - def add_patient_team_relationships(patient_ids: nil) contributing_subqueries.each do |key, subquery| source_key = connection.quote(PatientTeam.sources.fetch(key.to_s)) diff --git a/spec/models/vaccination_record_spec.rb b/spec/models/vaccination_record_spec.rb index 03a2916745..8049139d51 100644 --- a/spec/models/vaccination_record_spec.rb +++ b/spec/models/vaccination_record_spec.rb @@ -499,33 +499,4 @@ end end end - - describe "#sync_patient_teams_table_on_patient_ids" do - context "when a patient has two vaccination records that contribute" do - let(:patient) { create(:patient) } - let(:team) { create(:team) } - let(:session) { create(:session, team:) } - let(:other_team) { create(:team) } - let(:other_session) { create(:session, team: other_team) } - let!(:vaccination_record_a) do - create(:vaccination_record, patient:, session:) - end - let!(:vaccination_record_b) do - create(:vaccination_record, patient:, session:) - end - - it "keeps the patient team relationship when one is updated" do - expect(patient.reload.teams).to contain_exactly(team) - - vaccination_record_a.update_columns(session_id: other_session.id) - described_class.all.sync_patient_teams_table_on_patient_ids( - [patient.id] - ) - expect(vaccination_record_a.team).to eq(other_team) - expect(vaccination_record_b.team).to eq(team) - - expect(patient.reload.teams).to contain_exactly(team, other_team) - end - end - end end From 3f2dc44ae57ff78a7ef3a1b2371b5fb6957caf7c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 12:14:55 +0000 Subject: [PATCH 30/63] Remove `insert_patient_teams_relationships` This removes the method and replaces its usage with the `PatientTeamUpdater`. Jira-Issue: MAV-2987 --- .../concerns/contributes_to_patient_teams.rb | 37 ------------------- ...1027190936_populate_patient_teams_table.rb | 9 ----- lib/tasks/patient_team.rake | 20 ---------- 3 files changed, 66 deletions(-) delete mode 100644 db/migrate/20251027190936_populate_patient_teams_table.rb diff --git a/app/models/concerns/contributes_to_patient_teams.rb b/app/models/concerns/contributes_to_patient_teams.rb index 479262f261..45160eb62b 100644 --- a/app/models/concerns/contributes_to_patient_teams.rb +++ b/app/models/concerns/contributes_to_patient_teams.rb @@ -108,43 +108,6 @@ def contributing_subqueries end end - def insert_patient_teams_relationships - transaction { add_patient_team_relationships } - end - - def add_patient_team_relationships(patient_ids: nil) - contributing_subqueries.each do |key, subquery| - source_key = connection.quote(PatientTeam.sources.fetch(key.to_s)) - patient_id_source = - connection.quote_string(subquery[:patient_id_source]) - team_id_source = connection.quote_string(subquery[:team_id_source]) - contribution_scope = subquery[:contribution_scope] - if patient_ids.present? - contribution_scope = - contribution_scope.where( - "#{patient_id_source} = ANY(ARRAY[?]::bigint[])", - patient_ids - ) - end - - insert_from = - contribution_scope - .select( - "#{patient_id_source} as patient_id", - "#{team_id_source} as team_id" - ) - .distinct - .to_sql - connection.execute <<-SQL - INSERT INTO patient_teams (patient_id, team_id, sources) - SELECT alias.patient_id, alias.team_id, ARRAY[#{source_key}] - FROM (#{insert_from}) as alias - ON CONFLICT (team_id, patient_id) DO UPDATE - SET sources = array_append(array_remove(patient_teams.sources,#{source_key}),#{source_key}) - SQL - end - end - private def join_vaccination_records_to_organisation diff --git a/db/migrate/20251027190936_populate_patient_teams_table.rb b/db/migrate/20251027190936_populate_patient_teams_table.rb deleted file mode 100644 index 45b1182275..0000000000 --- a/db/migrate/20251027190936_populate_patient_teams_table.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class PopulatePatientTeamsTable < ActiveRecord::Migration[8.0] - def change - [PatientLocation, SchoolMove, ArchiveReason, VaccinationRecord].each do - it.all.insert_patient_teams_relationships - end - end -end diff --git a/lib/tasks/patient_team.rake b/lib/tasks/patient_team.rake index c1807f2cad..26bcfcd172 100644 --- a/lib/tasks/patient_team.rake +++ b/lib/tasks/patient_team.rake @@ -19,24 +19,4 @@ namespace :patient_team do PatientTeamUpdater.call(team_scope:) end end - - desc "Sync patient teams relationships" - task sync: :environment do - puts "Starting patient teams sync..." - - models = [PatientLocation, SchoolMove, ArchiveReason, VaccinationRecord] - - models.each do |model| - puts "Processing #{model.name}..." - begin - model.all.insert_patient_teams_relationships - puts "✓ Successfully synced #{model.name} with patient teams." - rescue StandardError => e - puts "✗ Error syncing #{model.name}: #{e.message}" - raise e - end - end - - puts "Patient teams sync completed successfully!" - end end From 653e593b852340b88cc4ad33da5d44f4dc23f53d Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 15 Jan 2026 14:18:36 +0000 Subject: [PATCH 31/63] Replace `ContributesToPatientTeams` This replaces the concern with a simpler one (now named `UpdatesPatientTeam`) which handles triggering the `PatientTeamUpdater` if the patient or the team of the associated model changes. Eventually this will be removed and replaced with explicit calls to `PatientTeamUpdater` in a similar way to the `StatusUpdater` works currently (MAV-2840). Jira-Issue: MAV-2987 --- app/models/archive_reason.rb | 6 +- .../concerns/contributes_to_patient_teams.rb | 266 ------------------ app/models/concerns/updates_patient_team.rb | 38 +++ app/models/organisation.rb | 5 - app/models/patient_location.rb | 6 +- app/models/school_move.rb | 6 +- app/models/session.rb | 5 - app/models/team.rb | 5 - app/models/team_location.rb | 6 +- app/models/vaccination_record.rb | 6 +- spec/models/patient_location_spec.rb | 51 ---- spec/policies/patient_policy_spec.rb | 2 + .../vaccination_record_policy_spec.rb | 7 +- 13 files changed, 51 insertions(+), 358 deletions(-) delete mode 100644 app/models/concerns/contributes_to_patient_teams.rb create mode 100644 app/models/concerns/updates_patient_team.rb diff --git a/app/models/archive_reason.rb b/app/models/archive_reason.rb index f26bfbf3e2..712c0494e5 100644 --- a/app/models/archive_reason.rb +++ b/app/models/archive_reason.rb @@ -28,11 +28,7 @@ # fk_rails_... (team_id => teams.id) # class ArchiveReason < ApplicationRecord - include ContributesToPatientTeams - - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end + include UpdatesPatientTeam self.inheritance_column = nil diff --git a/app/models/concerns/contributes_to_patient_teams.rb b/app/models/concerns/contributes_to_patient_teams.rb deleted file mode 100644 index 45160eb62b..0000000000 --- a/app/models/concerns/contributes_to_patient_teams.rb +++ /dev/null @@ -1,266 +0,0 @@ -# frozen_string_literal: true - -module ContributesToPatientTeams - extend ActiveSupport::Concern - - module Relation - def contributing_subqueries - case table_name - when "patient_locations" - { - patient_location: { - patient_id_source: "patient_locations.patient_id", - team_id_source: "team_locations.team_id", - contribution_scope: joins_team_locations - } - } - when "archive_reasons" - { - archive_reason: { - patient_id_source: "archive_reasons.patient_id", - team_id_source: "archive_reasons.team_id", - contribution_scope: all - } - } - when "vaccination_records" - { - vaccination_record_session: { - patient_id_source: "vaccination_records.patient_id", - team_id_source: "team_locations.team_id", - contribution_scope: joins(session: :team_location) - }, - vaccination_record_organisation: { - patient_id_source: "vaccination_records.patient_id", - team_id_source: "teams.id", - contribution_scope: - joins_teams_on_performed_ods_code.where(session_id: nil) - }, - vaccination_record_import: { - patient_id_source: "vaccination_records.patient_id", - team_id_source: "immunisation_imports.team_id", - contribution_scope: joins(:immunisation_imports) - } - } - when "school_moves" - { - school_move_team: { - patient_id_source: "school_moves.patient_id", - team_id_source: "school_moves.team_id", - contribution_scope: where("school_moves.team_id IS NOT NULL") - }, - school_move_school: { - patient_id_source: "school_moves.patient_id", - team_id_source: "tl.team_id", - contribution_scope: - joins(join_team_locations_to_school_moves).where("loc.type = 0") - } - } - when "sessions" - { - vaccination_record_session: { - patient_id_source: "vaccination_records.patient_id", - team_id_source: "team_locations.team_id", - contribution_scope: - joins(:team_location).joins(:vaccination_records) - } - } - when "organisations" - { - vaccination_record_organisation: { - patient_id_source: "vacs.patient_id", - team_id_source: "teams.id", - contribution_scope: - joins(join_vaccination_records_to_organisation).joins( - :teams - ).where("vacs.session_id IS NULL") - } - } - when "teams" - { - school_move_school: { - patient_id_source: "schlm.patient_id", - team_id_source: "team_locations.team_id", - contribution_scope: - joins(:schools).joins(join_school_moves_to_team_locations) - }, - vaccination_record_organisation: { - patient_id_source: "vacs.patient_id", - team_id_source: "teams.id", - contribution_scope: - joins(:organisation).joins( - join_vaccination_records_to_organisation - ).where("vacs.session_id IS NULL") - } - } - when "team_locations" - { - school_move_school: { - patient_id_source: "schlm.patient_id", - team_id_source: "team_locations.team_id", - contribution_scope: - joins(:location).merge(Location.school).joins( - join_school_moves_to_team_locations - ) - } - } - else - raise "Unknown table for PatientTeamContributor" - end - end - - private - - def join_vaccination_records_to_organisation - <<-SQL - INNER JOIN vaccination_records vacs - ON vacs.performed_ods_code = organisations.ods_code - SQL - end - - def join_team_locations_to_school_moves - <<-SQL - INNER JOIN locations loc - ON school_moves.school_id = loc.id - INNER JOIN team_locations tl - ON loc.id = tl.location_id - AND school_moves.academic_year = tl.academic_year - SQL - end - - def join_school_moves_to_team_locations - <<-SQL - INNER JOIN school_moves schlm - ON schlm.school_id = team_locations.team_id - AND schlm.academic_year = team_locations.academic_year - SQL - end - end - - included do - after_create :after_create_add_source_to_patient_teams - around_update :around_update_sync_source_of_patient_teams - around_destroy :around_destroy_remove_source_from_patient_teams - end - - private - - def after_create_add_source_to_patient_teams - fetch_source_and_patient_team_ids.each do |source, patient_team_ids| - patient_team_ids.each do |patient_id, team_id| - PatientTeam.find_or_initialize_by(patient_id:, team_id:).add_source!( - source - ) - end - end - end - - def around_update_sync_source_of_patient_teams - old_patient_team_ids = fetch_source_and_patient_team_ids - - yield - - new_patient_team_ids = fetch_source_and_patient_team_ids - - kept_patient_team_ids = - fetch_source_and_still_existing_patient_team_ids(old_patient_team_ids) - - removed_patient_team_ids = - old_patient_team_ids - .map { |key, value| [key, (value - kept_patient_team_ids[key])] } - .to_h - - inserted_patient_team_ids = - new_patient_team_ids - .map { |key, value| [key, (value - kept_patient_team_ids[key])] } - .to_h - - removed_patient_team_ids.each do |source, patient_team_ids| - patient_team_ids.each do |patient_id, team_id| - PatientTeam.find_by(patient_id:, team_id:)&.remove_source!(source) - end - end - - inserted_patient_team_ids.each do |source, patient_team_ids| - patient_team_ids.each do |patient_id, team_id| - PatientTeam.find_or_initialize_by(patient_id:, team_id:).add_source!( - source - ) - end - end - end - - def around_destroy_remove_source_from_patient_teams - affected_patient_team_ids = fetch_source_and_patient_team_ids - - yield - - kept_patient_team_ids = - fetch_source_and_still_existing_patient_team_ids( - affected_patient_team_ids - ) - - removed_patient_team_ids = - affected_patient_team_ids - .map { |key, value| [key, (value - kept_patient_team_ids[key])] } - .to_h - - removed_patient_team_ids.each do |source, patient_team_ids| - patient_team_ids.each do |patient_id, team_id| - PatientTeam.find_by(patient_id:, team_id:)&.remove_source!(source) - end - end - end - - def fetch_source_and_patient_team_ids - self - .class - .where(id:) - .contributing_subqueries - .transform_values do |subquery| - subquery - .fetch(:contribution_scope) - .distinct - .pluck( - subquery.fetch(:patient_id_source), - subquery.fetch(:team_id_source) - ) - end - end - - def fetch_source_and_still_existing_patient_team_ids( - patient_team_ids_by_source - ) - # This method find patient teams that still need to exist even if another - # contributing row has been updated or deleted. This works by searching - # for all the rows and then filtering on patient IDs and team IDs. - - self - .class - .all - .contributing_subqueries - .each_with_object({}) do |(source, subquery), hash| - patient_team_ids = patient_team_ids_by_source.fetch(source) - - hash[source] = if patient_team_ids.present? - patient_id_source = subquery.fetch(:patient_id_source) - team_id_source = subquery.fetch(:team_id_source) - - in_query_string = patient_team_ids.map { "(#{_1},#{_2})" }.join(",") - - where_clause = - ActiveRecord::Base.connection.quote_string( - "(#{patient_id_source}, #{team_id_source}) " \ - " IN (#{in_query_string})" - ) - - subquery - .fetch(:contribution_scope) - .where(where_clause) - .distinct - .pluck(patient_id_source, team_id_source) - else - [] - end - end - end -end diff --git a/app/models/concerns/updates_patient_team.rb b/app/models/concerns/updates_patient_team.rb new file mode 100644 index 0000000000..faae20c8da --- /dev/null +++ b/app/models/concerns/updates_patient_team.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module UpdatesPatientTeam + extend ActiveSupport::Concern + + included do + after_save :update_patient_team + after_destroy :update_patient_team + end + + private + + def update_patient_team + if should_update_patient_team? + PatientTeamUpdater.call( + patient_scope: patient_scope_for_update_patient_team, + team_scope: team_scope_for_update_patient_team + ) + end + end + + def should_update_patient_team? + try(:patient_id_previous_change).present? || + try(:team_id_previous_change).present? + end + + def patient_scope_for_update_patient_team + if (previous_change = try(:patient_id_previous_change)).present? + Patient.where(id: previous_change.compact) + end + end + + def team_scope_for_update_patient_team + if (previous_change = try(:team_id_previous_change)).present? + Team.where(id: previous_change.compact) + end + end +end diff --git a/app/models/organisation.rb b/app/models/organisation.rb index 7779ef31b0..16d940f4be 100644 --- a/app/models/organisation.rb +++ b/app/models/organisation.rb @@ -14,14 +14,9 @@ # index_organisations_on_ods_code (ods_code) UNIQUE # class Organisation < ApplicationRecord - include ContributesToPatientTeams include FlipperActor include ODSCodeConcern - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end - audited has_associated_audits diff --git a/app/models/patient_location.rb b/app/models/patient_location.rb index f6e05f8ff9..4ba19b8dbc 100644 --- a/app/models/patient_location.rb +++ b/app/models/patient_location.rb @@ -25,11 +25,7 @@ # class PatientLocation < ApplicationRecord - include ContributesToPatientTeams - - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end + include UpdatesPatientTeam audited associated_with: :patient has_associated_audits diff --git a/app/models/school_move.rb b/app/models/school_move.rb index a598ac3b3c..b961489a5d 100644 --- a/app/models/school_move.rb +++ b/app/models/school_move.rb @@ -28,13 +28,9 @@ # fk_rails_... (team_id => teams.id) # class SchoolMove < ApplicationRecord - include ContributesToPatientTeams include Schoolable include SchoolMovesHelper - - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end + include UpdatesPatientTeam audited associated_with: :patient diff --git a/app/models/session.rb b/app/models/session.rb index d0fc5ad636..e722653f08 100644 --- a/app/models/session.rb +++ b/app/models/session.rb @@ -29,15 +29,10 @@ class Session < ApplicationRecord include BelongsToTeamLocation include Consentable - include ContributesToPatientTeams include DaysBeforeToWeeksBefore include Delegatable include GelatineVaccinesConcern - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end - has_many :consent_notifications has_many :notes has_many :session_notifications diff --git a/app/models/team.rb b/app/models/team.rb index 75d2ecd3d8..835a73ac08 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -35,16 +35,11 @@ # fk_rails_... (organisation_id => organisations.id) # class Team < ApplicationRecord - include ContributesToPatientTeams include DaysBeforeToWeeksBefore include FlipperActor include HasManyProgrammes include HasManyTeamLocations - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end - UPLOAD_ONLY_YEAR_GROUPS = (-2..13).to_a.freeze audited associated_with: :organisation diff --git a/app/models/team_location.rb b/app/models/team_location.rb index 57c0b5c0e2..d59ee0e47f 100644 --- a/app/models/team_location.rb +++ b/app/models/team_location.rb @@ -27,11 +27,7 @@ # class TeamLocation < ApplicationRecord - include ContributesToPatientTeams - - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end + include UpdatesPatientTeam audited associated_with: :team has_associated_audits diff --git a/app/models/vaccination_record.rb b/app/models/vaccination_record.rb index ad0c181871..ee5ea907ec 100644 --- a/app/models/vaccination_record.rb +++ b/app/models/vaccination_record.rb @@ -77,18 +77,14 @@ class VaccinationRecord < ApplicationRecord include BelongsToProgramme include Confirmable - include ContributesToPatientTeams include Discard::Model include HasDoseVolume include Notable include PendingChangesConcern + include UpdatesPatientTeam include VaccinationRecordPerformedByConcern include VaccinationRecordSyncToNHSImmunisationsAPIConcern - class ActiveRecord_Relation < ActiveRecord::Relation - include ContributesToPatientTeams::Relation - end - audited associated_with: :patient DELIVERY_SITE_SNOMED_CODES_AND_TERMS = { diff --git a/spec/models/patient_location_spec.rb b/spec/models/patient_location_spec.rb index 7785e2760f..87f25f0d55 100644 --- a/spec/models/patient_location_spec.rb +++ b/spec/models/patient_location_spec.rb @@ -52,57 +52,6 @@ expect { patient_location.destroy! }.to change(PatientTeam, :count).by(-1) end - - it "creates patient teams in bulk" do - patient_location.update!(academic_year: 2000) # no sessions exist for this academic year - - expect(PatientTeam.count).to eq(0) - - expect { - described_class.where(id: patient_location.id).update_all( - academic_year: session.academic_year - ) - PatientTeamUpdater.call( - patient_scope: Patient.where(id: patient_location.patient_id) - ) - }.to change(PatientTeam, :count).by(1) - - patient_team = PatientTeam.last - expect(patient_team.patient_id).to eq(patient_location.patient_id) - expect(patient_team.team_id).to eq(session.team_id) - expect(patient_team.sources).to eq(%w[patient_location]) - end - - context "when a patient has two patient locations that contribute" do - let(:patient) { create(:patient) } - let(:team) { create(:team) } - let(:session_a) { create(:session, team:) } - let(:session_b) { create(:session, team:) } - let!(:patient_location_a) do - create(:patient_location, patient:, session: session_a) - end - let!(:patient_location_b) do - create(:patient_location, patient:, session: session_b) - end - - it "keeps the patient team relationship when one is destroyed" do - expect(patient.reload.teams).to contain_exactly(team) - - patient_location_a.destroy! - expect(patient_location_b).not_to be_nil - - expect(patient.reload.teams).to contain_exactly(team) - end - - it "keeps the patient team relationship when one is updated" do - expect(patient.reload.teams).to contain_exactly(team) - - patient_location_a.update!(location: create(:school)) - expect(patient_location_b).not_to be_nil - - expect(patient.reload.teams).to contain_exactly(team) - end - end end describe "scopes" do diff --git a/spec/policies/patient_policy_spec.rb b/spec/policies/patient_policy_spec.rb index bff2d33033..bc56e4285f 100644 --- a/spec/policies/patient_policy_spec.rb +++ b/spec/policies/patient_policy_spec.rb @@ -127,6 +127,8 @@ let(:patient_with_another_vaccination_record) { create(:patient) } before do + team # ensure a team exists + create( :vaccination_record, patient: patient_with_vaccination_record, diff --git a/spec/policies/vaccination_record_policy_spec.rb b/spec/policies/vaccination_record_policy_spec.rb index dd00b36b77..17b4a5ef03 100644 --- a/spec/policies/vaccination_record_policy_spec.rb +++ b/spec/policies/vaccination_record_policy_spec.rb @@ -148,7 +148,12 @@ end let(:non_team_kept_batch) { create(:vaccination_record, programme:) } let(:vaccination_record_same_organisation_different_team) do - create(:vaccination_record, session: other_session, programme:) + create( + :vaccination_record, + team: other_team, + session: other_session, + programme: + ) end let( :vaccination_record_from_different_organisation_but_patient_in_same_team From 2a6c3d7b4b778d393cffc40ee1c28945f691383a Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 16 Jan 2026 09:39:58 +0000 Subject: [PATCH 32/63] Optimise `PatientTeamUpdater` This introduces some optimisations to the `PatientTeamUpdater` class to hopefully speed up performance of updating patient teams. Specifically, this reduces the number of queries we make to the database by performing a single query using `UNION ALL` to fetch all the results, and then using `array_agg` to combine the sources in to a single array. Jira-Issue: MAV-2987 --- app/lib/patient_team_updater.rb | 149 +++++++++++++--------- config/brakeman.ignore | 212 +++++++++++++++++++++++++++++++- 2 files changed, 299 insertions(+), 62 deletions(-) diff --git a/app/lib/patient_team_updater.rb b/app/lib/patient_team_updater.rb index 24a36a1602..05678e2392 100644 --- a/app/lib/patient_team_updater.rb +++ b/app/lib/patient_team_updater.rb @@ -20,68 +20,73 @@ def self.call(...) = new(...).call attr_reader :patient_scope, :team_scope def upsert_patient_teams! - patient_team_rows.in_groups_of(10_000, false) do |rows| - PatientTeam.import!( - %i[patient_id team_id sources], - rows, - on_duplicate_key_update: { - conflict_target: %i[patient_id team_id], - columns: %i[sources] - } - ) - end + PatientTeam.connection.execute(<<~SQL) + INSERT INTO patient_teams (patient_id, team_id, sources) + #{grouped_relations_sql} + ON CONFLICT (patient_id, team_id) + DO UPDATE SET sources = EXCLUDED.sources + WHERE patient_teams.sources IS DISTINCT FROM EXCLUDED.sources + SQL end def delete_patient_teams_without_sources! PatientTeam.missing_sources.delete_all end - def patient_team_rows - @patient_team_rows ||= - patient_team_sources.map do |(patient_id, team_id), sources| - [patient_id, team_id, sources] - end + def grouped_relations_sql + union_all_sql = + relations.map(&:to_sql).join(" UNION ALL ").then { Arel.sql(it) } + + PatientTeam + .from("(#{union_all_sql})") + .group(:patient_id, :team_id) + .select( + :patient_id, + :team_id, + "array_remove(array_agg(DISTINCT source ORDER BY source), NULL)" + ) + .to_sql end - def patient_team_sources - @patient_team_sources ||= - sources.each_with_object( - existing_patient_team_pairs - ) do |(source, patient_team_pairs), hash| - patient_team_pairs.each do |patient_team_pair| - hash[patient_team_pair] ||= [] - hash[patient_team_pair] << PatientTeam.sources.fetch(source) - end - end + # These make up the various tables that contribute towards a patient + # belonging to a particular team. + + def relations + @relations ||= [ + archive_reason_relation, + null_relation, + patient_location_relation, + school_move_school_relation, + school_move_team_relation, + vaccination_record_import_relation, + vaccination_record_organisation_relation, + vaccination_record_session_relation + ] end - def existing_patient_team_pairs - scope = merge_team_scope(merge_patient_scope(PatientTeam)) + def archive_reason_relation + source = PatientTeam.sources.fetch("archive_reason") - scope.pluck(:patient_id, :team_id).index_with { |_pair| [] } - end + scope = merge_team_scope(merge_patient_scope(ArchiveReason)) - def sources - @sources ||= { - archive_reason: archive_reasons, - patient_location: patient_locations, - school_move_school: school_moves_by_school, - school_move_team: school_moves_by_team, - vaccination_record_import: vaccination_records_by_import, - vaccination_record_organisation: vaccination_records_by_organisation, - vaccination_record_session: vaccination_records_by_session - } + scope.select(:patient_id, :team_id, Arel.sql("#{source} AS source")) end - def archive_reasons - scope = merge_team_scope(merge_patient_scope(ArchiveReason)) + def null_relation + # This relation represents all the existing patient teams that exist for + # this patient scope and team scope. It ensures that if any existing + # patient teams no longer exist in the other relations, the sources array + # will be empty and then can be upserted. + + source = "NULL" + + scope = merge_team_scope(merge_patient_scope(PatientTeam)) - scope.pluck(:patient_id, :team_id) + scope.select(:patient_id, :team_id, Arel.sql("#{source} AS source")) end - def patient_locations - # We define an alias here in case the `patient_scope` already includes a - # join on the `team_locations` table. + def patient_location_relation + source = PatientTeam.sources.fetch("patient_location") scope = merge_patient_scope( @@ -95,12 +100,15 @@ def patient_locations scope = joins_teams_on_team_locations_alias(scope).merge(team_scope) end - scope.pluck(:patient_id, :"team_locations_alias.team_id") + scope.select( + :patient_id, + Arel.sql("team_locations_alias.team_id AS team_id"), + Arel.sql("#{source} AS source") + ) end - def school_moves_by_school - # We define an alias here in case the `patient_scope` already includes a - # join on the `team_locations` table. + def school_move_school_relation + source = PatientTeam.sources.fetch("school_move_school") scope = merge_patient_scope( @@ -114,17 +122,25 @@ def school_moves_by_school scope = joins_teams_on_team_locations_alias(scope).merge(team_scope) end - scope.pluck(:patient_id, :"team_locations_alias.team_id") + scope.select( + :patient_id, + Arel.sql("team_locations_alias.team_id AS team_id"), + Arel.sql("#{source} AS source") + ) end - def school_moves_by_team + def school_move_team_relation + source = PatientTeam.sources.fetch("school_move_team") + scope = merge_team_scope(merge_patient_scope(SchoolMove.where.not(team_id: nil))) - scope.pluck(:patient_id, :team_id) + scope.select(:patient_id, :team_id, Arel.sql("#{source} AS source")) end - def vaccination_records_by_import + def vaccination_record_import_relation + source = PatientTeam.sources.fetch("vaccination_record_import") + scope = merge_patient_scope(VaccinationRecord.joins(:immunisation_imports)) if is_team_scope_id_only? @@ -134,10 +150,16 @@ def vaccination_records_by_import scope = scope.joins(immunisation_imports: :team).merge(team_scope) end - scope.pluck(:patient_id, :"immunisation_imports.team_id") + scope.select( + :patient_id, + Arel.sql("immunisation_imports.team_id AS team_id"), + Arel.sql("#{source} AS source") + ) end - def vaccination_records_by_organisation + def vaccination_record_organisation_relation + source = PatientTeam.sources.fetch("vaccination_record_organisation") + scope = merge_patient_scope( VaccinationRecord.where( @@ -147,12 +169,15 @@ def vaccination_records_by_organisation scope = scope.merge(team_scope) if team_scope - scope.pluck(:patient_id, :"teams.id") + scope.select( + :patient_id, + Arel.sql("teams.id AS team_id"), + Arel.sql("#{source} AS source") + ) end - def vaccination_records_by_session - # We define an alias here in case the `patient_scope` already includes a - # join on the `sessions` table or `team_locations` table. + def vaccination_record_session_relation + source = PatientTeam.sources.fetch("vaccination_record_session") scope = merge_patient_scope( @@ -166,7 +191,11 @@ def vaccination_records_by_session scope = joins_teams_on_team_locations_alias(scope).merge(team_scope) end - scope.pluck(:patient_id, :"team_locations_alias.team_id") + scope.select( + :patient_id, + Arel.sql("team_locations_alias.team_id AS team_id"), + Arel.sql("#{source} AS source") + ) end # These are aliased joins that we need to perform in case the patient or diff --git a/config/brakeman.ignore b/config/brakeman.ignore index 1d6661b9e8..7740d7ad94 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,4 +1,212 @@ { - "ignored_warnings": [], - "brakeman_version": "7.1.1" + "ignored_warnings": [ + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "336dbfa5f403b71e5bd33a6ec67873ac00511af1743adf339d98385852b76f07", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 99, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"patient_location\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "patient_location_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"patient_location\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "3628881526fa627af0852fa8ae8a4aaa06ce30767a2a38ab5765024d5374835a", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 131, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"school_move_team\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "school_move_team_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"school_move_team\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "495955f72061c30c1528a0bdc839c51b97fc376f69ca29802f74deb992254f8b", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 149, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"vaccination_record_import\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "vaccination_record_import_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"vaccination_record_import\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "571bba144a3b21c05458504535da1dfa47897cad190d3d199113824853ae398d", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 190, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"vaccination_record_session\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "vaccination_record_session_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"vaccination_record_session\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "a3bb489f128d1bdffd4339aa748434faf2edd03d2b90e33687ecacd5cf3c3feb", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 168, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"vaccination_record_organisation\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "vaccination_record_organisation_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"vaccination_record_organisation\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "a81fbfc74625abc55a5683733921a5c9ed302913f2332b8c56dc42dac4de01ce", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 78, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"archive_reason\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "archive_reason_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"archive_reason\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "e0ae2813e5268e5a8b23aefe319570dedd63202093c2021a98553d1d08b42111", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 25, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "PatientTeam.connection.execute(\"INSERT INTO patient_teams (patient_id, team_id, sources)\\n#{grouped_relations_sql}\\nON CONFLICT (patient_id, team_id)\\nDO UPDATE SET sources = EXCLUDED.sources\\nWHERE patient_teams.sources IS DISTINCT FROM EXCLUDED.sources\\n\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "upsert_patient_teams!" + }, + "user_input": "grouped_relations_sql", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "f1ee1dd7da4b6008fae9adf049ca8b83a156050110d2f2edb8e920e651731916", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 41, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "PatientTeam.from(\"(#{relations.map(&:to_sql).join(\" UNION ALL \").then do\n Arel.sql(it)\n end})\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "grouped_relations_sql" + }, + "user_input": "relations.map(&:to_sql).join(\" UNION ALL \").then do\n Arel.sql(it)\n end", + "confidence": "Medium", + "cwe_id": [ + 89 + ], + "note": "" + }, + { + "warning_type": "SQL Injection", + "warning_code": 0, + "fingerprint": "f8daca0ab91af7452fabcd0c52414aa5cc4819a1e1e1f02a532390d5b45a6e35", + "check_name": "SQL", + "message": "Possible SQL injection", + "file": "app/lib/patient_team_updater.rb", + "line": 121, + "link": "https://brakemanscanner.org/docs/warning_types/sql_injection/", + "code": "Arel.sql(\"#{PatientTeam.sources.fetch(\"school_move_school\")} AS source\")", + "render_path": null, + "location": { + "type": "method", + "class": "PatientTeamUpdater", + "method": "school_move_school_relation" + }, + "user_input": "PatientTeam.sources.fetch(\"school_move_school\")", + "confidence": "High", + "cwe_id": [ + 89 + ], + "note": "" + } + ], + "brakeman_version": "7.1.2" } From be640e653d2025fadd1bfc01cf04afe50cd8ad48 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Fri, 16 Jan 2026 18:04:33 +0000 Subject: [PATCH 33/63] Fix "Incorrect vaccine given" warning when editing MMRV records Fixes a bug where editing an MMRV vaccination record would incorrectly show a warning message saying the vaccine was incorrect. The issue occurred because `StatusGenerator::Triage#status` returns :delay_vaccination for MMR/MMRV catch-ups when the course is incomplete. Jira-Issue: MAV-3065 --- app/models/draft_vaccination_record.rb | 2 +- spec/models/draft_vaccination_record_spec.rb | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index d944c7c094..899b4f0e57 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -290,7 +290,7 @@ def vaccine_method_matches_consent_and_triage? ) approved_vaccine_methods = - if triage_generator.status == :not_required + if triage_generator.status.in?(%i[not_required delay_vaccination]) consent_generator.vaccine_methods else [triage_generator.vaccine_method].compact diff --git a/spec/models/draft_vaccination_record_spec.rb b/spec/models/draft_vaccination_record_spec.rb index 4266efcb71..4e6ef60cf6 100644 --- a/spec/models/draft_vaccination_record_spec.rb +++ b/spec/models/draft_vaccination_record_spec.rb @@ -484,6 +484,23 @@ def draft_vaccination_record_with_source(source_value) it { should be(false) } end + + context "when triage is delay vaccination" do + let(:programme) { Programme.mmr } + + before do + create(:consent, :given_nasal, patient:, programme:) + create( + :triage, + :delay_vaccination, + patient:, + programme:, + delay_vaccination_until: 1.month.from_now + ) + end + + it { should be(true) } + end end context "when delivery method is intramuscular" do From 13b3ffa7540173af3cd88359a55b258e789bba54 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Fri, 16 Jan 2026 11:54:41 +0000 Subject: [PATCH 34/63] Add CLI Subteams::List command Allows us to see subteams in a team or overall without having to hop in and out of console. --- app/lib/mavis_cli.rb | 1 + app/lib/mavis_cli/subteams/list.rb | 47 ++++++++++++++ spec/features/cli_subteams_list_spec.rb | 84 +++++++++++++++++++++++++ 3 files changed, 132 insertions(+) create mode 100644 app/lib/mavis_cli/subteams/list.rb create mode 100644 spec/features/cli_subteams_list_spec.rb diff --git a/app/lib/mavis_cli.rb b/app/lib/mavis_cli.rb index d018126292..884f53fd45 100644 --- a/app/lib/mavis_cli.rb +++ b/app/lib/mavis_cli.rb @@ -54,6 +54,7 @@ def self.progress_bar(total) require_relative "mavis_cli/stats/vaccinations" require_relative "mavis_cli/stats/sessions" require_relative "mavis_cli/subteams/create" +require_relative "mavis_cli/subteams/list" require_relative "mavis_cli/teams/add_programme" require_relative "mavis_cli/teams/list" require_relative "mavis_cli/teams/onboard" diff --git a/app/lib/mavis_cli/subteams/list.rb b/app/lib/mavis_cli/subteams/list.rb new file mode 100644 index 0000000000..da8f8dcdea --- /dev/null +++ b/app/lib/mavis_cli/subteams/list.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module MavisCLI + module Subteams + class List < Dry::CLI::Command + desc "List subteams in Mavis" + + option :team_workgroup, + desc: "The workgroup of the team to list subteams for" + + def call(team_workgroup: nil) + MavisCLI.load_rails + + teams = + if team_workgroup.present? + team = Team.find_by(workgroup: team_workgroup) + if team.nil? + warn "Could not find team with workgroup #{team_workgroup}." + return + end + + team.subteams + else + Subteam.all + end + + rows = + teams.find_each.map do |subteam| + subteam.slice(:id, :name, :team_id).merge( + team_workgroup: subteam.team.workgroup, + team_programmes: subteam.team.programmes.map(&:name).join(", ") + ) + end + + puts TableTennis.new( + rows, + columns: %i[id name team_id team_workgroup team_programmes], + zebra: true + ) + end + end + end + + register "subteams" do |prefix| + prefix.register "list", Subteams::List + end +end diff --git a/spec/features/cli_subteams_list_spec.rb b/spec/features/cli_subteams_list_spec.rb new file mode 100644 index 0000000000..e4971c0399 --- /dev/null +++ b/spec/features/cli_subteams_list_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require_relative "../../app/lib/mavis_cli" + +describe "mavis subteams list" do + it "lists all subteams" do + given_a_couple_teams_exist + and_there_are_subteams_in_the_teams + when_i_run_the_list_subteams_command + then_i_should_see_the_list_of_subteams + end + + it "lists subteams for one org" do + given_a_couple_teams_exist + and_there_are_subteams_in_the_teams + when_i_run_the_list_subteams_command_with_a_workgroup + then_i_should_see_the_subteams_for_just_that_team + end + + context "Team does not exist" do + it "returns an error message" do + when_i_run_the_list_subteams_command_with_an_invalid_workgroup + then_i_should_see_a_team_doesnt_exist_message + end + end + + def given_a_couple_teams_exist + @programme = Programme.sample + @team1 = create(:team, programmes: [@programme]) + @team2 = create(:team, programmes: [@programme]) + end + + def and_there_are_subteams_in_the_teams + @subteam1 = create(:subteam, team: @team1) + @subteam2 = create(:subteam, team: @team2) + end + + def when_i_run_the_list_subteams_command + @output = + capture_output do + Dry::CLI.new(MavisCLI).call(arguments: %w[subteams list]) + end + end + + def when_i_run_the_list_subteams_command_with_a_workgroup + @output = + capture_output do + Dry::CLI.new(MavisCLI).call( + arguments: ["subteams", "list", "-t", @team1.workgroup] + ) + end + end + + def when_i_run_the_list_subteams_command_with_an_invalid_workgroup + @output = + capture_error do + Dry::CLI.new(MavisCLI).call( + arguments: %w[subteams list -t invalid_workgroup] + ) + end + end + + def then_i_should_see_the_list_of_subteams + expect(@output).to include("#{@subteam1.id} │ #{@subteam1.name}") + expect(@output).to include("#{@team1.id} │ #{@team1.workgroup}") + expect(@output).to include("#{@subteam2.id} │ #{@subteam2.name}") + expect(@output).to include("#{@team2.id} │ #{@team2.workgroup}") + expect(@output).to include(@programme.name).twice + end + + def then_i_should_see_the_subteams_for_just_that_team + expect(@output).to include("#{@subteam1.id} │ #{@subteam1.name}") + expect(@output).to include("#{@team1.id} │ #{@team1.workgroup}") + expect(@output).not_to include("#{@subteam2.id} │ #{@subteam2.name}") + expect(@output).not_to include("#{@team2.id} │ #{@team2.workgroup}") + expect(@output).to include(@programme.name).once + end + + def then_i_should_see_a_team_doesnt_exist_message + expect(@output).to include( + "Could not find team with workgroup invalid_workgroup." + ) + end +end From 1e8de6029166c4b37320648275ba9c60871dbe2c Mon Sep 17 00:00:00 2001 From: John Henderson Date: Thu, 15 Jan 2026 09:09:48 +0000 Subject: [PATCH 35/63] Remove `programme_types` from NotifyLogEntry This column can now be removed as we have migrated to using `notify_log_entry_programmes`. Jira-Issue: MAV-2943 --- app/models/notify_log_entry.rb | 4 ---- ...remove_programme_types_from_notify_log_entries.rb | 12 ++++++++++++ db/schema.rb | 4 +--- spec/factories/notify_log_entries.rb | 2 -- spec/models/notify_log_entry_spec.rb | 2 -- 5 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 db/migrate/20260115090835_remove_programme_types_from_notify_log_entries.rb diff --git a/app/models/notify_log_entry.rb b/app/models/notify_log_entry.rb index 734bf9022e..101be0ab79 100644 --- a/app/models/notify_log_entry.rb +++ b/app/models/notify_log_entry.rb @@ -6,7 +6,6 @@ # # id :bigint not null, primary key # delivery_status :integer default("sending"), not null -# programme_types :enum default([]), not null, is an Array # recipient :string not null # type :integer not null # created_at :datetime not null @@ -23,7 +22,6 @@ # index_notify_log_entries_on_delivery_id (delivery_id) # index_notify_log_entries_on_parent_id (parent_id) # index_notify_log_entries_on_patient_id (patient_id) -# index_notify_log_entries_on_programme_types (programme_types) USING gin # index_notify_log_entries_on_sent_by_user_id (sent_by_user_id) # # Foreign Keys @@ -36,8 +34,6 @@ class NotifyLogEntry < ApplicationRecord include Sendable - self.ignored_columns += ["programme_types"] - self.inheritance_column = nil belongs_to :consent_form, optional: true diff --git a/db/migrate/20260115090835_remove_programme_types_from_notify_log_entries.rb b/db/migrate/20260115090835_remove_programme_types_from_notify_log_entries.rb new file mode 100644 index 0000000000..423086e88f --- /dev/null +++ b/db/migrate/20260115090835_remove_programme_types_from_notify_log_entries.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class RemoveProgrammeTypesFromNotifyLogEntries < ActiveRecord::Migration[8.1] + def change + remove_column :notify_log_entries, + :programme_types, + :enum, + default: [], + array: true, + null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 10e685d760..dbb502dbb1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_01_13_123500) do +ActiveRecord::Schema[8.1].define(version: 2026_01_15_090835) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -523,7 +523,6 @@ t.integer "delivery_status", default: 0, null: false t.bigint "parent_id" t.bigint "patient_id" - t.enum "programme_types", default: [], null: false, array: true, enum_type: "programme_type" t.string "recipient", null: false t.bigint "sent_by_user_id" t.uuid "template_id", null: false @@ -532,7 +531,6 @@ t.index ["delivery_id"], name: "index_notify_log_entries_on_delivery_id" t.index ["parent_id"], name: "index_notify_log_entries_on_parent_id" t.index ["patient_id"], name: "index_notify_log_entries_on_patient_id" - t.index ["programme_types"], name: "index_notify_log_entries_on_programme_types", using: :gin t.index ["sent_by_user_id"], name: "index_notify_log_entries_on_sent_by_user_id" end diff --git a/spec/factories/notify_log_entries.rb b/spec/factories/notify_log_entries.rb index 7885bbbdbd..449fa866a1 100644 --- a/spec/factories/notify_log_entries.rb +++ b/spec/factories/notify_log_entries.rb @@ -6,7 +6,6 @@ # # id :bigint not null, primary key # delivery_status :integer default("sending"), not null -# programme_types :enum default([]), not null, is an Array # recipient :string not null # type :integer not null # created_at :datetime not null @@ -23,7 +22,6 @@ # index_notify_log_entries_on_delivery_id (delivery_id) # index_notify_log_entries_on_parent_id (parent_id) # index_notify_log_entries_on_patient_id (patient_id) -# index_notify_log_entries_on_programme_types (programme_types) USING gin # index_notify_log_entries_on_sent_by_user_id (sent_by_user_id) # # Foreign Keys diff --git a/spec/models/notify_log_entry_spec.rb b/spec/models/notify_log_entry_spec.rb index e8822b090b..2f86702ad8 100644 --- a/spec/models/notify_log_entry_spec.rb +++ b/spec/models/notify_log_entry_spec.rb @@ -6,7 +6,6 @@ # # id :bigint not null, primary key # delivery_status :integer default("sending"), not null -# programme_types :enum default([]), not null, is an Array # recipient :string not null # type :integer not null # created_at :datetime not null @@ -23,7 +22,6 @@ # index_notify_log_entries_on_delivery_id (delivery_id) # index_notify_log_entries_on_parent_id (parent_id) # index_notify_log_entries_on_patient_id (patient_id) -# index_notify_log_entries_on_programme_types (programme_types) USING gin # index_notify_log_entries_on_sent_by_user_id (sent_by_user_id) # # Foreign Keys From 631e98bf45c4c73b2a99b38df04b0bd7cc9beebf Mon Sep 17 00:00:00 2001 From: John Henderson Date: Mon, 19 Jan 2026 09:30:26 +0000 Subject: [PATCH 36/63] Remove data migration for backfilling `NotifyLogEntry::Programme` The backfill task is no longer needed now that the migration is complete. Jira-Issue: MAV-2943 --- .../backfill_notify_log_entry_programmes.rb | 43 ------------------- 1 file changed, 43 deletions(-) delete mode 100644 app/lib/data_migration/backfill_notify_log_entry_programmes.rb diff --git a/app/lib/data_migration/backfill_notify_log_entry_programmes.rb b/app/lib/data_migration/backfill_notify_log_entry_programmes.rb deleted file mode 100644 index c070b0c12b..0000000000 --- a/app/lib/data_migration/backfill_notify_log_entry_programmes.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -class DataMigration::BackfillNotifyLogEntryProgrammes - def call - progress_bar = - # rubocop:disable Rails/SaveBang - ProgressBar.create( - total: NotifyLogEntry.count, - format: "%a %b\u{15E7}%i %p%% %t", - progress_mark: " ", - remainder_mark: "\u{FF65}" - ) - # rubocop:enable Rails/SaveBang - - NotifyLogEntry.find_in_batches(batch_size: 10_000) do |notify_log_entries| - notify_log_entry_programmes = - notify_log_entries.flat_map do |notify_log_entry| - notify_log_entry.programmes.map do |programme| - disease_types = - if programme.mmr? - Programme::Variant::DISEASE_TYPES.fetch("mmr") - else - programme.disease_types - end - [notify_log_entry.id, programme.type, disease_types] - end - end - - NotifyLogEntry::Programme.import!( - %i[notify_log_entry_id programme_type disease_types], - notify_log_entry_programmes, - on_duplicate_key_ignore: true - ) - progress_bar.progress += notify_log_entries.size - end - - progress_bar.finish - end - - def self.call(...) = new(...).call - - private_class_method :new -end From 2452f9bd9ab7420a516f9445106a0cdf79bc3d77 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Mon, 12 Jan 2026 12:50:30 +0000 Subject: [PATCH 37/63] Enable creation of school sites through onboarding configuration Allow school site information to be added to onboarding files to reduce operational workload post-onboarding. Sites can be specified in the schools section of the onboarding file using a hash format with required `urn`, `name`, and `site` fields. Address fields (`address_line_1`, `address_line_2`, `address_town`, `address_postcode`) can optionally be provided to override the original location's address details. A validation ensures that URNs cannot appear as both regular schools and sites to prevent configuration conflicts. Example configuration: ``` schools: subteam_name: - 123456 # Regular school by URN - urn: 234567 name: School Name (Site A) site: A address_line_1: 123 Street address_postcode: AB1 2CD - urn: 234567 name: School Name (Site B) site: B ``` --- app/models/onboarding.rb | 101 +++++++++++++++++++- spec/features/cli_teams_onboard_spec.rb | 70 +++++++++++++- spec/fixtures/files/onboarding/invalid.yaml | 12 ++- spec/fixtures/files/onboarding/valid.yaml | 21 +++- spec/models/onboarding_spec.rb | 17 +++- spec/requests/api/testing/onboard_spec.rb | 7 ++ 6 files changed, 213 insertions(+), 15 deletions(-) diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb index b19bd61461..1221cb08f9 100644 --- a/app/models/onboarding.rb +++ b/app/models/onboarding.rb @@ -53,6 +53,7 @@ class Onboarding validates :subteams, presence: true validates :schools, presence: true validates :clinics, presence: true + validate :no_duplicate_urns_across_school_types def initialize(hash) config = hash.deep_symbolize_keys @@ -95,10 +96,32 @@ def initialize(hash) @schools = config .fetch(:schools, {}) - .flat_map do |team_name, school_urns| + .flat_map do |team_name, schools| subteam = subteams_by_name[team_name] - school_urns.map do |urn| - ExistingSchool.new(urn:, subteam:, programmes:) + schools.map do |config| + if config.is_a?(Hash) + urn = config.fetch(:urn) + name = config.fetch(:name, nil) + site = config.fetch(:site, nil) + address_line_1 = config.fetch(:address_line_1, nil) + address_line_2 = config.fetch(:address_line_2, nil) + address_town = config.fetch(:address_town, nil) + address_postcode = config.fetch(:address_postcode, nil) + NewSchoolSite.new( + urn:, + name:, + site:, + address_line_1:, + address_line_2:, + address_town:, + address_postcode:, + subteam:, + programmes: + ) + else + urn = config + ExistingSchool.new(urn:, subteam:, programmes:) + end end end @@ -115,6 +138,22 @@ def initialize(hash) end end + def no_duplicate_urns_across_school_types + existing_school_urns = + schools.select { it.is_a?(ExistingSchool) }.map(&:urn) + + site_urns = schools.select { it.is_a?(NewSchoolSite) }.map(&:urn) + + overlapping_urns = existing_school_urns & site_urns + + if overlapping_urns.any? + errors.add( + :schools, + "URN(s) #{overlapping_urns.join(", ")} cannot appear as both a regular school and a site" + ) + end + end + def valid?(context = nil) ([super] + models.map(&:valid?)).all? end @@ -237,4 +276,60 @@ def attach_to_team!(academic_year:) ) end end + + class NewSchoolSite + include ActiveModel::Model + + attr_accessor :urn, + :name, + :site, + :address_line_1, + :address_line_2, + :address_town, + :address_postcode, + :subteam, + :programmes + + validates :location, presence: true + validates :subteam, presence: true + validates :status, inclusion: %w[open opening] + validates :name, presence: true + validates :site, presence: true + + def original_location + @original_location ||= Location.school.find_by_urn_and_site(urn) + end + + def location + return nil unless original_location + + @location ||= + original_location.dup.tap do |loc| + loc.assign_attributes( + { + name:, + site:, + address_line_1:, + address_line_2:, + address_town:, + address_postcode: + }.compact + ) + end + end + + delegate :status, to: :location, allow_nil: true + delegate :team, to: :subteam + + delegate :save!, to: :location + + def attach_to_team!(academic_year:) + location.attach_to_team!(team, academic_year:, subteam:) + location.import_year_groups_from_gias!(academic_year:) + location.import_default_programme_year_groups!( + programmes.map(&:programme), + academic_year: + ) + end + end end diff --git a/spec/features/cli_teams_onboard_spec.rb b/spec/features/cli_teams_onboard_spec.rb index 365f9c64d2..3a87a8f9ca 100644 --- a/spec/features/cli_teams_onboard_spec.rb +++ b/spec/features/cli_teams_onboard_spec.rb @@ -9,11 +9,13 @@ when_i_run_the_valid_command then_i_see_no_output and_a_new_team_is_created + and_schools_are_added_to_the_team_appropriately end end context "with an invalid configuration" do it "displays an error message" do + given_programmes_and_schools_exist when_i_run_the_invalid_command then_i_see_an_error_message end @@ -64,10 +66,38 @@ def command_for_training def given_programmes_and_schools_exist Programme.hpv - create(:school, :secondary, :open, urn: "123456") - create(:school, :secondary, :open, urn: "234567") - create(:school, :secondary, :open, urn: "345678") - create(:school, :secondary, :open, urn: "456789") + @school_a = + create( + :school, + :secondary, + :open, + urn: "123456", + name: "Existing School 1" + ) + @school_b = + create( + :school, + :secondary, + :open, + urn: "234567", + name: "Existing School 2" + ) + @school_c = + create( + :school, + :secondary, + :open, + urn: "345678", + name: "Existing School 3" + ) + @school_d = + create( + :school, + :secondary, + :open, + urn: "456789", + name: "Existing School 4" + ) end def when_i_run_the_valid_command @@ -90,7 +120,39 @@ def and_a_new_team_is_created expect(Team.count).to eq(1) end + def and_schools_are_added_to_the_team_appropriately + expect(Team.last.schools.count).to eq(6) + school_b_sites = Location.where(urn: @school_b.urn).where.not(site: nil) + school_d_sites = Location.where(urn: @school_d.urn).where.not(site: nil) + expect(Team.last.schools).to include( + @school_a, + @school_c, + *school_b_sites, + *school_d_sites + ) + + expect(school_b_sites.count).to eq(2) + expect(school_b_sites.map { it.teams.count }).to eq([1, 1]) + expect(school_b_sites.map(&:name)).to eq( + ["Existing School 2 (Site A)", "Existing School 2 (Site B)"] + ) + expect(@school_b.teams).to be_empty + + expect(school_d_sites.count).to eq(2) + expect(school_d_sites.map { it.teams.count }).to eq([1, 1]) + expect(school_d_sites.map(&:name)).to eq( + ["Existing School 4 (Site A)", "Existing School 4 (Site B)"] + ) + expect(school_d_sites.find_by(site: "B").address_line_1).to eq( + "456 High St" + ) + expect(@school_d.teams).to be_empty + end + def then_i_see_an_error_message expect(@output).to include("Programmes can't be blank") + expect(@output).to include( + "Schools URN(s) 456789 cannot appear as both a regular school and a site" + ) end end diff --git a/spec/fixtures/files/onboarding/invalid.yaml b/spec/fixtures/files/onboarding/invalid.yaml index 85fa29bd22..d283dd4b7b 100644 --- a/spec/fixtures/files/onboarding/invalid.yaml +++ b/spec/fixtures/files/onboarding/invalid.yaml @@ -7,4 +7,14 @@ subteams: schools: unknown_subteam: [123456, 234567] - subteam_1: [567890] + subteam_1: + - 456789 + - urn: 456789 + site: "A" + name: "Existing School 4 (Site A)" + - urn: 456789 + site: "B" + name: "Existing School 4 (Site B)" + address_line_1: "456 High St" + address_town: "London" + address_postcode: "SW1A 1AA" diff --git a/spec/fixtures/files/onboarding/valid.yaml b/spec/fixtures/files/onboarding/valid.yaml index 20096ed2f9..cdf2e3ff8c 100644 --- a/spec/fixtures/files/onboarding/valid.yaml +++ b/spec/fixtures/files/onboarding/valid.yaml @@ -27,8 +27,25 @@ subteams: phone: 07700 900817 schools: - subteam_1: [123456, 234567] - subteam_2: [345678, 456789] + subteam_1: + - 123456 + - urn: 234567 + site: "A" + name: "Existing School 2 (Site A)" + - urn: 234567 + site: "B" + name: "Existing School 2 (Site B)" + subteam_2: + - 345678 + - urn: 456789 + site: "A" + name: "Existing School 4 (Site A)" + - urn: 456789 + site: "B" + name: "Existing School 4 (Site B)" + address_line_1: "456 High St" + address_town: "London" + address_postcode: "SW1A 1AA" clinics: subteam_1: diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb index 3be02695b0..98dd4b3a56 100644 --- a/spec/models/onboarding_spec.rb +++ b/spec/models/onboarding_spec.rb @@ -49,13 +49,18 @@ expect(subteam2.phone).to eq("07700 900817") expect(subteam2.reply_to_id).to be_nil - expect(subteam1.schools).to contain_exactly(school1, school2) - expect(subteam2.schools).to contain_exactly(school3, school4) + school2_sites = Location.where(urn: school2.urn).where.not(site: nil) + school4_sites = Location.where(urn: school4.urn).where.not(site: nil) + + expect(subteam1.schools).to contain_exactly(school1, *school2_sites) + expect(subteam2.schools).to contain_exactly(school3, *school4_sites) expect(school1.location_programme_year_groups.count).to eq(4) - expect(school2.location_programme_year_groups.count).to eq(4) + expect(school2_sites.first.location_programme_year_groups.count).to eq(4) + expect(school2_sites.second.location_programme_year_groups.count).to eq(4) expect(school3.location_programme_year_groups.count).to eq(4) - expect(school4.location_programme_year_groups.count).to eq(4) + expect(school4_sites.first.location_programme_year_groups.count).to eq(4) + expect(school4_sites.second.location_programme_year_groups.count).to eq(4) clinic1 = subteam1.community_clinics.find_by!(ods_code: nil) expect(clinic1.name).to eq("10 Downing Street") @@ -87,7 +92,9 @@ "team.workgroup": ["can't be blank"], "school.0.subteam": ["can't be blank"], "school.1.subteam": ["can't be blank"], - "school.2.status": ["is not included in the list"], + schools: [ + "URN(s) 456789 cannot appear as both a regular school and a site" + ], "subteam.email": ["can't be blank"], "subteam.name": ["can't be blank"], clinics: ["can't be blank"], diff --git a/spec/requests/api/testing/onboard_spec.rb b/spec/requests/api/testing/onboard_spec.rb index f8ca576013..53a134ff68 100644 --- a/spec/requests/api/testing/onboard_spec.rb +++ b/spec/requests/api/testing/onboard_spec.rb @@ -67,6 +67,13 @@ "school.1.subteam" => ["can't be blank"], "school.2.location" => ["can't be blank"], "school.2.status" => ["is not included in the list"], + "school.3.location" => ["can't be blank"], + "school.3.status" => ["is not included in the list"], + "school.4.location" => ["can't be blank"], + "school.4.status" => ["is not included in the list"], + "schools" => [ + "URN(s) 456789 cannot appear as both a regular school and a site" + ], "subteam.email" => ["can't be blank"], "subteam.name" => ["can't be blank"] } From f734dc7d54cbdc2e3aa73acc799dba2bc3242d33 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 19 Jan 2026 09:55:34 +0000 Subject: [PATCH 38/63] Add documentation for `PatientTeamUpdater` This adds some documentation for the new `PatientTeamUpdater` class as requested as part of a review of the class. Jira-Issue: MAV-2987 --- app/lib/patient_team_updater.rb | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/lib/patient_team_updater.rb b/app/lib/patient_team_updater.rb index 05678e2392..05305876a2 100644 --- a/app/lib/patient_team_updater.rb +++ b/app/lib/patient_team_updater.rb @@ -1,5 +1,29 @@ # frozen_string_literal: true +## +# This class is used to update the patient-team associations for any +# particular set of patients or teams. Patient-team associations are a cache +# of which teams have access to view which patients, and the logic depends on +# a number of associated tables. For example, a patient is considered part of +# a team if they have an archive reason, or vaccination record for that team. +# +# A `patient_scope` and `team_scope` can be passed in to limit the scope of +# which patients and teams get updated. Without these arguments the default +# is to update all the patients and teams. +# +# If a patient or team scope is provided which has just a where clause on the +# ID (for example `Patient.where(id: 10)`), the class will attempt an +# optimisation which avoids a join and instead filters directly on the ID in +# the relation. +# +# The updater works by executing two queries. The first query is a bulk update +# using `INSERT ... ON CONFLICT DO UPDATE` to update all the patient-team +# associations by setting the array of sources for each association. The +# sources array contains all the possible reasons why a patient might be +# associated with a team (for example `archive_reason` or +# `vaccination_record_organisation`). The second query deletes any rows from +# the table where the sources array is empty (meaning the patient should not +# belong to the team.). class PatientTeamUpdater def initialize(patient_scope: nil, team_scope: nil) @patient_scope = patient_scope From 4ed54bc8ea93e6f45102bd2a1d2fc4bafe4638dd Mon Sep 17 00:00:00 2001 From: Jake Benilov Date: Mon, 19 Jan 2026 09:58:44 +0000 Subject: [PATCH 39/63] Realign SNOMED procedure terms post-rationalisation JIRA: https://nhsd-jira.digital.nhs.uk/browse/MAV-3076 --- app/models/vaccine.rb | 14 +++++++------- spec/fixtures/files/fhir/immunisation_create.json | 2 +- spec/fixtures/files/fhir/immunisation_update.json | 2 +- spec/lib/fhir_mapper/vaccination_record_spec.rb | 2 +- spec/lib/fhir_mapper/vaccine_spec.rb | 2 +- spec/models/vaccine_spec.rb | 4 ++-- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/app/models/vaccine.rb b/app/models/vaccine.rb index 3058b48456..afd8858a00 100644 --- a/app/models/vaccine.rb +++ b/app/models/vaccine.rb @@ -140,7 +140,7 @@ def self.delivery_method_to_vaccine_method(delivery_method) code: "761841000", term: "Administration of vaccine product containing only Human " \ - "papillomavirus antigen (procedure)" + "papillomavirus antigen" } }, "menacwy" => { @@ -148,7 +148,7 @@ def self.delivery_method_to_vaccine_method(delivery_method) code: "871874000", term: "Administration of vaccine product containing only Neisseria " \ - "meningitidis serogroup A, C, W135 and Y antigens (procedure)" + "meningitidis serogroup A, C, W135 and Y antigens" } }, "mmr" => { @@ -157,7 +157,7 @@ def self.delivery_method_to_vaccine_method(delivery_method) term: "Administration of vaccine product containing only Measles " \ "morbillivirus and Mumps orthorubulavirus and Rubella virus " \ - "antigens (procedure)" + "antigens" } }, "mmrv" => { @@ -166,16 +166,16 @@ def self.delivery_method_to_vaccine_method(delivery_method) term: "Administration of vaccine product containing only Human " \ "alphaherpesvirus 3 and Measles morbillivirus and Mumps " \ - "orthorubulavirus and Rubella virus antigens (procedure)" + "orthorubulavirus and Rubella virus antigens" } }, "td_ipv" => { "injection" => { - code: "866186002", + code: "414619005", term: "Administration of vaccine product containing only Clostridium " \ - "tetani and Corynebacterium diphtheriae and Human poliovirus " \ - "antigens (procedure)" + "tetani and low dose Corynebacterium diphtheriae and inactivated " \ + "Human poliovirus antigens" } } }.freeze diff --git a/spec/fixtures/files/fhir/immunisation_create.json b/spec/fixtures/files/fhir/immunisation_create.json index 5cbfee05b1..e4777aecaf 100644 --- a/spec/fixtures/files/fhir/immunisation_create.json +++ b/spec/fixtures/files/fhir/immunisation_create.json @@ -46,7 +46,7 @@ { "system": "http://snomed.info/sct", "code": "761841000", - "display": "Administration of vaccine product containing only Human papillomavirus antigen (procedure)" + "display": "Administration of vaccine product containing only Human papillomavirus antigen" } ] } diff --git a/spec/fixtures/files/fhir/immunisation_update.json b/spec/fixtures/files/fhir/immunisation_update.json index bad8ea4e7b..f2760bcddc 100644 --- a/spec/fixtures/files/fhir/immunisation_update.json +++ b/spec/fixtures/files/fhir/immunisation_update.json @@ -47,7 +47,7 @@ { "system": "http://snomed.info/sct", "code": "761841000", - "display": "Administration of vaccine product containing only Human papillomavirus antigen (procedure)" + "display": "Administration of vaccine product containing only Human papillomavirus antigen" } ] } diff --git a/spec/lib/fhir_mapper/vaccination_record_spec.rb b/spec/lib/fhir_mapper/vaccination_record_spec.rb index 60d0aa561e..af2103b457 100644 --- a/spec/lib/fhir_mapper/vaccination_record_spec.rb +++ b/spec/lib/fhir_mapper/vaccination_record_spec.rb @@ -152,7 +152,7 @@ its(:display) do should eq "Administration of vaccine product containing only Human " \ - "papillomavirus antigen (procedure)" + "papillomavirus antigen" end end end diff --git a/spec/lib/fhir_mapper/vaccine_spec.rb b/spec/lib/fhir_mapper/vaccine_spec.rb index 87c25d2bad..031b203fea 100644 --- a/spec/lib/fhir_mapper/vaccine_spec.rb +++ b/spec/lib/fhir_mapper/vaccine_spec.rb @@ -51,7 +51,7 @@ its(:display) do should eq( - "Administration of vaccine product containing only Human papillomavirus antigen (procedure)" + "Administration of vaccine product containing only Human papillomavirus antigen" ) end end diff --git a/spec/models/vaccine_spec.rb b/spec/models/vaccine_spec.rb index 500b4fa9f8..22b32c0a99 100644 --- a/spec/models/vaccine_spec.rb +++ b/spec/models/vaccine_spec.rb @@ -151,7 +151,7 @@ expect(vaccine.snomed_procedure_term).to eq( "Administration of vaccine product containing only Measles " \ "morbillivirus and Mumps orthorubulavirus and Rubella virus " \ - "antigens (procedure)" + "antigens" ) end end @@ -165,7 +165,7 @@ expect(vaccine.snomed_procedure_term).to eq( "Administration of vaccine product containing only Human " \ "alphaherpesvirus 3 and Measles morbillivirus and Mumps " \ - "orthorubulavirus and Rubella virus antigens (procedure)" + "orthorubulavirus and Rubella virus antigens" ) end end From 037c930ac1eb89aa6ded3d9de705650a30c69883 Mon Sep 17 00:00:00 2001 From: Mike Thompson Date: Mon, 19 Jan 2026 11:05:25 +0000 Subject: [PATCH 40/63] Fix reporting api root URL It's /reports, not /reporting. --- config/settings.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/settings.yml b/config/settings.yml index d67144e1c6..f8fbc27d94 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -54,7 +54,7 @@ splunk: reporting_api: client_app: token_ttl_seconds: 600 - root_url: http://manage-vaccinations-in-schools.nhs.uk/reporting/ + root_url: https://manage-vaccinations-in-schools.nhs.uk/reports/ client_id: <%= Rails.application.credentials.reporting_api&.client_id %> secret: <%= Rails.application.credentials.reporting_api&.secret %> From fc776428e9f7854685858bd8ad69244584769315 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Mon, 19 Jan 2026 13:25:47 +0000 Subject: [PATCH 41/63] Fix flaky CLI subteams list spec Format subteam IDs with delimiter to match CLI output (e.g. 1,500) when Team ID is greater than 999. --- spec/features/cli_subteams_list_spec.rb | 34 +++++++++++++++++++------ 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/spec/features/cli_subteams_list_spec.rb b/spec/features/cli_subteams_list_spec.rb index e4971c0399..fb78ad3f35 100644 --- a/spec/features/cli_subteams_list_spec.rb +++ b/spec/features/cli_subteams_list_spec.rb @@ -3,6 +3,8 @@ require_relative "../../app/lib/mavis_cli" describe "mavis subteams list" do + include ActionView::Helpers::NumberHelper + it "lists all subteams" do given_a_couple_teams_exist and_there_are_subteams_in_the_teams @@ -61,18 +63,34 @@ def when_i_run_the_list_subteams_command_with_an_invalid_workgroup end def then_i_should_see_the_list_of_subteams - expect(@output).to include("#{@subteam1.id} │ #{@subteam1.name}") - expect(@output).to include("#{@team1.id} │ #{@team1.workgroup}") - expect(@output).to include("#{@subteam2.id} │ #{@subteam2.name}") - expect(@output).to include("#{@team2.id} │ #{@team2.workgroup}") + expect(@output).to include( + "#{number_with_delimiter(@subteam1.id)} │ #{@subteam1.name}" + ) + expect(@output).to include( + "#{number_with_delimiter(@team1.id)} │ #{@team1.workgroup}" + ) + expect(@output).to include( + "#{number_with_delimiter(@subteam2.id)} │ #{@subteam2.name}" + ) + expect(@output).to include( + "#{number_with_delimiter(@team2.id)} │ #{@team2.workgroup}" + ) expect(@output).to include(@programme.name).twice end def then_i_should_see_the_subteams_for_just_that_team - expect(@output).to include("#{@subteam1.id} │ #{@subteam1.name}") - expect(@output).to include("#{@team1.id} │ #{@team1.workgroup}") - expect(@output).not_to include("#{@subteam2.id} │ #{@subteam2.name}") - expect(@output).not_to include("#{@team2.id} │ #{@team2.workgroup}") + expect(@output).to include( + "#{number_with_delimiter(@subteam1.id)} │ #{@subteam1.name}" + ) + expect(@output).to include( + "#{number_with_delimiter(@team1.id)} │ #{@team1.workgroup}" + ) + expect(@output).not_to include( + "#{number_with_delimiter(@subteam2.id)} │ #{@subteam2.name}" + ) + expect(@output).not_to include( + "#{number_with_delimiter(@team2.id)} │ #{@team2.workgroup}" + ) expect(@output).to include(@programme.name).once end From cdf5d93a08a1c72ad0f9fdf47b6ce0101caa775d Mon Sep 17 00:00:00 2001 From: John Henderson Date: Mon, 19 Jan 2026 14:02:27 +0000 Subject: [PATCH 42/63] Prevent duplicate triage records when editing MMR/MMRV vaccinations `NextDoseTriageFactory` was creating a new triage record every time a vaccination record was updated. This resulted in redundant "Next dose" triage notes appearing for the same vaccination event. This change adds a guard to `NextDoseTriageFactory` to skip creation if the vaccination record already has an associated `next_dose_delay_triage`. Jira-Issue: MAV-3064 --- app/lib/next_dose_triage_factory.rb | 2 ++ spec/lib/next_dose_triage_factory_spec.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/lib/next_dose_triage_factory.rb b/app/lib/next_dose_triage_factory.rb index df04e9fbf6..c6a1dffdf1 100644 --- a/app/lib/next_dose_triage_factory.rb +++ b/app/lib/next_dose_triage_factory.rb @@ -28,6 +28,8 @@ def self.call(...) = new(...).call delegate :academic_year, :patient, :programme, to: :vaccination_record def should_create? + return false if vaccination_record.next_dose_delay_triage_id.present? + return false unless vaccination_record.administered? && programme.mmr? return false if next_date.past? diff --git a/spec/lib/next_dose_triage_factory_spec.rb b/spec/lib/next_dose_triage_factory_spec.rb index 66d8ec7883..a0321151d4 100644 --- a/spec/lib/next_dose_triage_factory_spec.rb +++ b/spec/lib/next_dose_triage_factory_spec.rb @@ -69,5 +69,17 @@ expect(triage.delay_vaccination_until).to eq(28.days.from_now.to_date) end end + + context "when a next dose triage record already exists" do + let(:vaccination_record) do + create(:vaccination_record, :administered, programme:) + end + + before { described_class.call(vaccination_record:) } + + it "does not create another triage record" do + expect { call }.not_to change(Triage, :count) + end + end end end From 5929c08aacace96bb2e82693d66678a882128073 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 14 Jan 2026 13:26:09 +0000 Subject: [PATCH 43/63] Allow bulk upload users to edit location They need to be able to only see schools, or select "Unknown". Jira-Issue: MAV-2911 --- .../draft_vaccination_records_controller.rb | 25 +++++++++++- app/models/draft_vaccination_record.rb | 25 ++++++++---- .../location.html.erb | 16 ++++++++ spec/features/edit_vaccination_record_spec.rb | 40 +++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index 3311975d92..04ccc3fab1 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -37,6 +37,8 @@ def update handle_date_and_time when :outcome handle_outcome + when :location + handle_location when :batch handle_batch when :confirm @@ -98,6 +100,22 @@ def handle_batch end end + def handle_location + if @draft_vaccination_record.bulk_upload_user_and_record? + parsed_location_id = + ( + if update_params[:location_id] == "unknown" + nil + else + update_params[:location_id] + end + ) + @draft_vaccination_record.location_id = parsed_location_id + @draft_vaccination_record.location_name = + (@draft_vaccination_record.location_id.present? ? nil : "Unknown") + end + end + def handle_confirm return unless @draft_vaccination_record.save @@ -224,7 +242,12 @@ def set_batches end def set_locations - @locations = policy_scope(Location).community_clinic + @locations = + if @draft_vaccination_record.bulk_upload_user_and_record? + Location.school.order(:name) + else + policy_scope(Location).community_clinic + end end def set_supplied_by_users diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 899b4f0e57..30fa232b08 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -58,7 +58,7 @@ def wizard_steps (:delivery if administered?), (:dose if administered? && can_be_half_dose?), (:batch if administered?), - (:location if session&.generic_clinic?), + (:location if session&.generic_clinic? || bulk_upload_user_and_record?), (:dose_sequence if bulk_upload_user_and_record?), (:vaccinator if bulk_upload_user_and_record?), :confirm @@ -100,7 +100,10 @@ def wizard_steps end on_wizard_step :location, exact: true do - validates :location_id, presence: true + validate :location_is_school, if: :bulk_upload_user_and_record? + validates :location_id, + presence: true, + unless: :bulk_upload_user_and_record? end on_wizard_step :notes, exact: true do @@ -237,6 +240,14 @@ def delivery_method=(value) def vaccine_id_changed? = batch_id_changed? + def location_is_school + return if location_id.blank? + + unless location&.school? + errors.add(:location_id, "The location must be a school") + end + end + def identity_check return nil if identity_check_confirmed_by_patient.nil? @@ -315,6 +326,11 @@ def sourced_from_consent_refusal? = source == "consent_refusal" def sourced_from_bulk_upload? = source == "bulk_upload" + def bulk_upload_user_and_record? + @current_user.selected_team.has_upload_only_access? && + sourced_from_bulk_upload? + end + private def readable_attribute_names @@ -406,11 +422,6 @@ def can_change_outcome? !bulk_upload_user_and_record? end - def bulk_upload_user_and_record? - @current_user.selected_team.has_upload_only_access? && - sourced_from_bulk_upload? - end - def requires_supplied_by? performed_by_user && !performed_by_user&.show_in_suppliers end diff --git a/app/views/draft_vaccination_records/location.html.erb b/app/views/draft_vaccination_records/location.html.erb index 8cc6be5546..202f271d46 100644 --- a/app/views/draft_vaccination_records/location.html.erb +++ b/app/views/draft_vaccination_records/location.html.erb @@ -8,6 +8,21 @@ <%= form_with model: @draft_vaccination_record, url: wizard_path, method: :put do |f| %> <%= f.govuk_error_summary %> +<% if @draft_vaccination_record.bulk_upload_user_and_record? %> + <%= f.govuk_select :location_id, + label: { text: "Location", size: "l", tag: "h1" }, + caption: { text: @patient.full_name }, + data: { module: "app-autocomplete" } do %> + + <% @locations.find_each do |school| %> + <%= tag.option location_display_name(school, show_urn: true), + value: school.id, + selected: school.id == @draft_vaccination_record.location&.id, + data: { hint: format_address_single_line(school) } %> + <% end %> + <%= tag.option "Unknown", value: "unknown", selected: @draft_vaccination_record.location.nil? %> + <% end %> +<% else %> <%= f.govuk_radio_buttons_fieldset :location_id, caption: { text: @patient.full_name, size: "l" }, legend: { size: "l", tag: "h1", @@ -19,6 +34,7 @@ hint: { text: format_address_single_line(location) } %> <% end %> <% end %> +<% end %> <%= f.govuk_submit "Continue" %> <% end %> diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index f025ed19f4..45e81b510f 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -304,6 +304,22 @@ then_i_see_the_edit_vaccination_record_page and_i_should_see_the_updated_dose_number + when_i_click_on_change_location + and_i_choose_location_unknown + then_i_see_the_edit_vaccination_record_page + and_i_should_see_location_unknown + + when_i_click_on_save_changes + then_i_should_see_the_vaccination_record + + when_i_click_on_edit_vaccination_record + then_i_see_the_edit_vaccination_record_page + + when_i_click_on_change_location + and_i_choose_a_school + then_i_see_the_edit_vaccination_record_page + and_i_should_see_the_updated_location + when_i_click_on_save_changes then_i_should_see_the_vaccination_record end @@ -393,6 +409,8 @@ def given_a_bulk_upload_team_exists year_group: 8 ) + @school = create(:school, name: "A New School") + @vaccine = @programme.vaccines.first @batch = create(:batch, team: @team, vaccine: @vaccine) @@ -696,6 +714,28 @@ def and_i_choose_a_delivery_method_and_site click_on "Continue" end + def when_i_click_on_change_location + click_on "Change location" + end + + def and_i_choose_location_unknown + select "Unknown" + click_on "Continue" + end + + def and_i_should_see_location_unknown + expect(page).to have_content("LocationUnknown") + end + + def and_i_choose_a_school + select "A New School" + click_on "Continue" + end + + def and_i_should_see_the_updated_location + expect(page).to have_content("LocationA New School") + end + def when_i_click_on_save_changes travel 1.minute click_on "Save changes" From ff255404a5bfa63cdb3de6e35ae31ebd34bf818a Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Thu, 15 Jan 2026 11:28:26 +0000 Subject: [PATCH 44/63] Refactor edit vaccination record tests Split them out to test each individual field editing separately Jira-Issue: MAV-2911 --- spec/features/edit_vaccination_record_spec.rb | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index 45e81b510f..553cc44aa6 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -290,20 +290,51 @@ when_i_click_back then_i_should_see_the_vaccination_record - and_i_click_on_edit_vaccination_record + + when_i_click_on_edit_vaccination_record then_i_see_the_edit_vaccination_record_page and_i_should_not_see_a_change_outcome_link + when_i_click_on_save_changes + then_i_should_see_the_vaccination_record + end + + scenario "Edits the vaccinator" do + given_i_am_signed_in + and_a_bulk_uploaded_vaccination_record_exists + + when_i_navigate_to_the_edit_vaccination_record_page + when_i_edit_the_vaccinator and_i_enter_a_new_first_name_and_last_name then_i_see_the_edit_vaccination_record_page and_i_should_see_the_updated_vaccinator_details + when_i_click_on_save_changes + then_i_should_see_the_vaccination_record + end + + scenario "Edits dose number" do + given_i_am_signed_in + and_a_bulk_uploaded_vaccination_record_exists + + when_i_navigate_to_the_edit_vaccination_record_page + when_i_click_on_change_dose_number and_i_choose_the_second_dose then_i_see_the_edit_vaccination_record_page and_i_should_see_the_updated_dose_number + when_i_click_on_save_changes + then_i_should_see_the_vaccination_record + end + + scenario "Edits the location" do + given_i_am_signed_in + and_a_bulk_uploaded_vaccination_record_exists + + when_i_navigate_to_the_edit_vaccination_record_page + when_i_click_on_change_location and_i_choose_location_unknown then_i_see_the_edit_vaccination_record_page @@ -552,6 +583,14 @@ def then_i_see_the_edit_vaccination_record_page ) end + def when_i_navigate_to_the_edit_vaccination_record_page + when_i_go_to_the_vaccination_record_for_the_patient + then_i_should_see_the_vaccination_record + + when_i_click_on_edit_vaccination_record + then_i_see_the_edit_vaccination_record_page + end + def when_i_click_back click_on "Back" end From 8c0fe870880bcc9bc262b108f9f21560d706097b Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Thu, 15 Jan 2026 13:26:18 +0000 Subject: [PATCH 45/63] Tweak content for bulk upload users when editing notes This is because they need to know that the notes won't be sent to NHSE. Jira-Issue: MAV-2914 --- .../draft_vaccination_records/notes.html.erb | 6 +++- spec/features/edit_vaccination_record_spec.rb | 35 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/views/draft_vaccination_records/notes.html.erb b/app/views/draft_vaccination_records/notes.html.erb index 5d52ac7e59..3f9e9296d8 100644 --- a/app/views/draft_vaccination_records/notes.html.erb +++ b/app/views/draft_vaccination_records/notes.html.erb @@ -10,7 +10,11 @@ <%= f.govuk_text_area :notes, caption: { text: @patient.full_name, size: "l" }, label: { text: "Notes", tag: "h1", size: "l" }, - hint: { text: "For example, if the child had a reaction to the vaccine" } %> + hint: { text: (if @draft_vaccination_record.bulk_upload_user_and_record? + "You can add notes here for your own use. They will not be sent to NHS England." + else + "For example, if the child had a reaction to the vaccine" + end) } %> <%= f.govuk_submit "Continue" %> <% end %> diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index 553cc44aa6..b2ef59299d 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -355,6 +355,22 @@ then_i_should_see_the_vaccination_record end + scenario "Edits notes" do + given_i_am_signed_in + and_a_bulk_uploaded_vaccination_record_exists + + when_i_navigate_to_the_edit_vaccination_record_page + + when_i_click_on_change_notes + then_i_should_see_different_help_text + when_i_enter_some_notes + then_i_see_the_edit_vaccination_record_page + and_i_should_see_the_new_notes + + when_i_click_on_save_changes + then_i_should_see_the_vaccination_record + end + scenario "Parent details are not visible when viewing vaccination records" do given_i_am_signed_in and_a_bulk_uploaded_vaccination_record_exists @@ -775,6 +791,25 @@ def and_i_should_see_the_updated_location expect(page).to have_content("LocationA New School") end + def when_i_click_on_change_notes + click_on "Add notes" + end + + def then_i_should_see_different_help_text + expect(page).to have_content( + "You can add notes here for your own use. They will not be sent to NHS England." + ) + end + + def when_i_enter_some_notes + fill_in "Notes", with: "Some notes." + click_on "Continue" + end + + def and_i_should_see_the_new_notes + expect(page).to have_content("NotesSome notes.") + end + def when_i_click_on_save_changes travel 1.minute click_on "Save changes" From dad0e689f87ab0a2a2e5c7f03bb3265d43eb049a Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Tue, 13 Jan 2026 19:09:15 +0000 Subject: [PATCH 46/63] Assume notify_parents is true if unset Vaccinations imported from national reporting will not have this set. Notifying parents is the default behaviour. Jira-Issue: MAV-2733 --- app/lib/nhs/immunisations_api.rb | 2 +- spec/lib/nhs/immunisations_api_spec.rb | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/lib/nhs/immunisations_api.rb b/app/lib/nhs/immunisations_api.rb index ba65f7f11b..a5f5a29dba 100644 --- a/app/lib/nhs/immunisations_api.rb +++ b/app/lib/nhs/immunisations_api.rb @@ -266,7 +266,7 @@ def should_be_in_immunisations_api?( vaccination_record.administered? && Flipper.enabled?(:imms_api_sync_job, vaccination_record.programme) && (ignore_nhs_number || vaccination_record.patient.nhs_number.present?) && - vaccination_record.notify_parents && + vaccination_record.notify_parents != false && vaccination_record.patient.not_invalidated? end diff --git a/spec/lib/nhs/immunisations_api_spec.rb b/spec/lib/nhs/immunisations_api_spec.rb index 6b8d1a32f2..bc098d9bf8 100644 --- a/spec/lib/nhs/immunisations_api_spec.rb +++ b/spec/lib/nhs/immunisations_api_spec.rb @@ -740,6 +740,12 @@ it { should be false } end + context "when notify_parents is not set" do + let(:notify_parents) { nil } + + it { should be true } + end + context "when the patient is invalidated" do before { patient.update(invalidated_at: Time.current) } From f769de2d80638f02d421321a51bc8e86343b5f5d Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Mon, 12 Jan 2026 10:36:38 +0000 Subject: [PATCH 47/63] Send national reporting vaccinations to imms api The primary reason for the national reporting service is to send vaccinations onward to NHSE systems. Jira-Issue: MAV-2733 --- app/lib/nhs/immunisations_api.rb | 3 +- ...d_sync_to_nhs_immunisations_api_concern.rb | 10 +++- .../import_vaccination_records_bulk_spec.rb | 17 +++++++ spec/lib/nhs/immunisations_api_spec.rb | 8 ++- ...c_to_nhs_immunisations_api_concern_spec.rb | 50 +++++++++++++++++++ spec/support/immunisations_api_helper.rb | 24 +++++---- 6 files changed, 98 insertions(+), 14 deletions(-) diff --git a/app/lib/nhs/immunisations_api.rb b/app/lib/nhs/immunisations_api.rb index a5f5a29dba..b9b760c916 100644 --- a/app/lib/nhs/immunisations_api.rb +++ b/app/lib/nhs/immunisations_api.rb @@ -262,7 +262,8 @@ def should_be_in_immunisations_api?( vaccination_record, ignore_nhs_number: false ) - vaccination_record.kept? && vaccination_record.sourced_from_service? && + vaccination_record.kept? && + vaccination_record.syncable_to_nhs_immunisations_api? && vaccination_record.administered? && Flipper.enabled?(:imms_api_sync_job, vaccination_record.programme) && (ignore_nhs_number || vaccination_record.patient.nhs_number.present?) && diff --git a/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb b/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb index 5972054c29..6b237adcb9 100644 --- a/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb +++ b/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb @@ -5,7 +5,11 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern included do scope :syncable_to_nhs_immunisations_api, - -> { includes(:patient).sourced_from_service } + -> do + includes(:patient).then do + it.sourced_from_service.or(it.sourced_from_bulk_upload) + end + end scope :sync_all_to_nhs_immunisations_api, -> do @@ -29,7 +33,9 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern after_commit :queue_sync_to_nhs_immunisations_api end - def syncable_to_nhs_immunisations_api? = sourced_from_service? + def syncable_to_nhs_immunisations_api? + (sourced_from_service? || sourced_from_bulk_upload?) + end def sync_status should_be_synced = diff --git a/spec/features/import_vaccination_records_bulk_spec.rb b/spec/features/import_vaccination_records_bulk_spec.rb index b5bdd4d0d8..4fd6d78dc7 100644 --- a/spec/features/import_vaccination_records_bulk_spec.rb +++ b/spec/features/import_vaccination_records_bulk_spec.rb @@ -7,6 +7,7 @@ given_mavis_logins_are_configured given_i_am_signed_in_as_a_bulk_upload_user given_a_patient_already_exists + and_sending_to_nhs_immunisations_api_is_enabled when_i_go_to_the_import_page then_i_should_see_the_upload_link @@ -29,6 +30,7 @@ and_the_patients_should_now_be_associated_with_the_team and_the_newly_created_patients_should_be_archived and_the_existing_patients_should_not_be_archived + and_the_vaccination_records_are_sent_to_the_imms_api when_i_click_on_a_vaccination_record then_i_should_see_the_vaccination_record @@ -86,6 +88,15 @@ def and_school_locations_exist create(:school, urn: "144012") end + def and_sending_to_nhs_immunisations_api_is_enabled + Flipper.enable(:imms_api_integration) + Flipper.enable(:imms_api_sync_job, Programme.flu) + Flipper.enable(:imms_api_sync_job, Programme.hpv) + + @stubbed_post_request = + stub_immunisations_api_post(Random.uuid, Random.uuid) + end + def when_i_go_to_the_import_page visit "/dashboard" @@ -189,6 +200,12 @@ def and_the_existing_patients_should_not_be_archived expect(@existing_patient.archived?(team: @team)).to be false end + def and_the_vaccination_records_are_sent_to_the_imms_api + SyncVaccinationRecordToNHSJob.drain + + expect(@stubbed_post_request).to have_been_requested.times(2) + end + def when_i_click_on_a_vaccination_record find(".nhsuk-details__summary", text: "2 imported records").click click_on "WEASLEY, Ron" diff --git a/spec/lib/nhs/immunisations_api_spec.rb b/spec/lib/nhs/immunisations_api_spec.rb index bc098d9bf8..e5e0cd3f2c 100644 --- a/spec/lib/nhs/immunisations_api_spec.rb +++ b/spec/lib/nhs/immunisations_api_spec.rb @@ -680,8 +680,12 @@ it { should be false } end - context "when the vaccination record is not recorded in service" do - let(:session) { nil } + context "when the vaccination record doesn't have the correct source" do + before do + allow(vaccination_record).to receive( + :syncable_to_nhs_immunisations_api? + ).and_return(false) + end it { should be false } end diff --git a/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb b/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb index edbac8aaef..9582cd718a 100644 --- a/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb +++ b/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb @@ -83,6 +83,30 @@ it { should include(vaccination_record) } it { should_not include(vaccination_record_outside_of_session) } + + context "when vaccination record was uploaded through national reporting portal" do + let!(:vaccination_record) do + create(:vaccination_record, :sourced_from_bulk_upload, programme:) + end + + it { should include(vaccination_record) } + end + + context "when vaccination record was part of a historical upload" do + let!(:vaccination_record) do + create(:vaccination_record, source: :historical_upload, programme:) + end + + it { should_not include(vaccination_record) } + end + + context "a vaccination record created because patient is already vaccinated" do + let!(:vaccination_record) do + create(:vaccination_record, source: :consent_refusal, programme:) + end + + it { should_not include(vaccination_record) } + end end describe "#syncable_to_nhs_immunisations_api?" do @@ -104,6 +128,32 @@ it { should be false } end + context "a vaccination record uploaded through national reporting portal" do + let(:vaccination_record) do + build( + :vaccination_record, + :sourced_from_bulk_upload, + outcome:, + programme: + ) + end + + it { should be true } + end + + context "a vaccination record created because patient is already vaccinated" do + let(:vaccination_record) do + build( + :vaccination_record, + source: :consent_refusal, + outcome:, + programme: + ) + end + + it { should be false } + end + context "a patient without an nhs number" do let(:patient) do create(:patient, nhs_number: nil, school: session.location) diff --git a/spec/support/immunisations_api_helper.rb b/spec/support/immunisations_api_helper.rb index cc770e58e0..49e6d82b44 100644 --- a/spec/support/immunisations_api_helper.rb +++ b/spec/support/immunisations_api_helper.rb @@ -1,18 +1,24 @@ # frozen_string_literal: true module ImmunisationsAPIHelper - def stub_immunisations_api_post(uuid: Random.uuid) + def stub_immunisations_api_post(*uuids, uuid: Random.uuid) + uuids << uuid + responses = + uuids.map do |id| + { + status: 201, + body: nil, + headers: { + location: + "https://localhost:4000/immunisation-fhir-api/Immunization/#{id}" + } + } + end + stub_request( :post, "https://sandbox.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization" - ).to_return( - status: 201, - body: nil, - headers: { - location: - "https://localhost:4000/immunisation-fhir-api/Immunization/#{uuid}" - } - ) + ).to_return(responses) end def stub_immunisations_api_put(uuid: Random.uuid) From 961ce7d15e0cec37c3a8bdb762abdf810c2c1091 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Tue, 13 Jan 2026 15:23:20 +0000 Subject: [PATCH 48/63] Remove unused methods in spec Jira-Issue: MAV-2733 --- .../import_vaccination_records_bulk_spec.rb | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/spec/features/import_vaccination_records_bulk_spec.rb b/spec/features/import_vaccination_records_bulk_spec.rb index 4fd6d78dc7..ce2be6cf94 100644 --- a/spec/features/import_vaccination_records_bulk_spec.rb +++ b/spec/features/import_vaccination_records_bulk_spec.rb @@ -82,12 +82,6 @@ def given_a_patient_already_exists create(:vaccination_record, patient: @existing_patient, team: @team) end - def and_school_locations_exist - create(:school, urn: "110158") - create(:school, urn: "120026") - create(:school, urn: "144012") - end - def and_sending_to_nhs_immunisations_api_is_enabled Flipper.enable(:imms_api_integration) Flipper.enable(:imms_api_sync_job, Programme.flu) @@ -111,16 +105,6 @@ def when_i_click_on_the_upload_link click_on "Upload records" end - def when_i_click_on_the_imports_tab - click_on "Imports" - end - - def and_i_choose_to_import_child_records - click_on "Upload records" - choose "Vaccination records" - click_on "Continue" - end - def then_i_should_see_the_upload_page expect(page).to have_content("Upload vaccination records") end From 018e3e29eb1187c1c3bd3d9c3e13a2453cc59a61 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Mon, 12 Jan 2026 10:36:38 +0000 Subject: [PATCH 49/63] Rename syncable_to_nhs_immunisations_api to ... with_correct_source_for_nhs_immunisations_api and correct_source_for_nhs_immunisations_api? Makes more sense since these methods are only there to check the source of the vaccination record. Jira-Issue: MAV-2733 --- app/lib/nhs/immunisations_api.rb | 2 +- ...ion_record_sync_to_nhs_immunisations_api_concern.rb | 10 +++++----- spec/lib/nhs/immunisations_api_spec.rb | 2 +- ...ecord_sync_to_nhs_immunisations_api_concern_spec.rb | 10 +++++----- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/lib/nhs/immunisations_api.rb b/app/lib/nhs/immunisations_api.rb index b9b760c916..19d514b9fc 100644 --- a/app/lib/nhs/immunisations_api.rb +++ b/app/lib/nhs/immunisations_api.rb @@ -263,7 +263,7 @@ def should_be_in_immunisations_api?( ignore_nhs_number: false ) vaccination_record.kept? && - vaccination_record.syncable_to_nhs_immunisations_api? && + vaccination_record.correct_source_for_nhs_immunisations_api? && vaccination_record.administered? && Flipper.enabled?(:imms_api_sync_job, vaccination_record.programme) && (ignore_nhs_number || vaccination_record.patient.nhs_number.present?) && diff --git a/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb b/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb index 6b237adcb9..76b1dd1826 100644 --- a/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb +++ b/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb @@ -4,7 +4,7 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern extend ActiveSupport::Concern included do - scope :syncable_to_nhs_immunisations_api, + scope :with_correct_source_for_nhs_immunisations_api, -> do includes(:patient).then do it.sourced_from_service.or(it.sourced_from_bulk_upload) @@ -17,7 +17,7 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern Programme.all.select { Flipper.enabled?(:imms_api_sync_job, it) } ids = - syncable_to_nhs_immunisations_api.for_programmes( + with_correct_source_for_nhs_immunisations_api.for_programmes( programmes ).pluck(:id) @@ -33,7 +33,7 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern after_commit :queue_sync_to_nhs_immunisations_api end - def syncable_to_nhs_immunisations_api? + def correct_source_for_nhs_immunisations_api? (sourced_from_service? || sourced_from_bulk_upload?) end @@ -67,14 +67,14 @@ def changes_need_to_be_synced_to_nhs_immunisations_api? def touch_nhs_immunisations_api_sync_pending_at return unless Flipper.enabled?(:imms_api_sync_job, programme) - return unless syncable_to_nhs_immunisations_api? + return unless correct_source_for_nhs_immunisations_api? self.nhs_immunisations_api_sync_pending_at = Time.current end def queue_sync_to_nhs_immunisations_api return unless Flipper.enabled?(:imms_api_sync_job, programme) - return unless syncable_to_nhs_immunisations_api? + return unless correct_source_for_nhs_immunisations_api? return if nhs_immunisations_api_sync_pending_at.nil? if nhs_immunisations_api_synced_at && diff --git a/spec/lib/nhs/immunisations_api_spec.rb b/spec/lib/nhs/immunisations_api_spec.rb index e5e0cd3f2c..0778c34156 100644 --- a/spec/lib/nhs/immunisations_api_spec.rb +++ b/spec/lib/nhs/immunisations_api_spec.rb @@ -683,7 +683,7 @@ context "when the vaccination record doesn't have the correct source" do before do allow(vaccination_record).to receive( - :syncable_to_nhs_immunisations_api? + :correct_source_for_nhs_immunisations_api? ).and_return(false) end diff --git a/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb b/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb index 9582cd718a..00a5edb971 100644 --- a/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb +++ b/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb @@ -29,7 +29,7 @@ context "when the vaccination record isn't syncable" do before do allow(vaccination_record).to receive( - :syncable_to_nhs_immunisations_api? + :correct_source_for_nhs_immunisations_api? ).and_return(false) end @@ -71,8 +71,8 @@ end end - describe "syncable_to_nhs_immunisations_api scope" do - subject { VaccinationRecord.syncable_to_nhs_immunisations_api } + describe "with_correct_source_for_nhs_immunisations_api scope" do + subject { VaccinationRecord.with_correct_source_for_nhs_immunisations_api } let!(:vaccination_record) do create(:vaccination_record, programme:, session:) @@ -109,8 +109,8 @@ end end - describe "#syncable_to_nhs_immunisations_api?" do - subject { vaccination_record.syncable_to_nhs_immunisations_api? } + describe "#correct_source_to_nhs_immunisations_api?" do + subject { vaccination_record.correct_source_for_nhs_immunisations_api? } context "when the vaccination record is eligible to sync" do it { should be true } From 80cb3e45ca136e2d54d601c01c82f8d3b6272105 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Tue, 13 Jan 2026 14:49:09 +0000 Subject: [PATCH 50/63] Add sync_national_reporting_to_imms_api feature ... ... flag We're planning to dual-run the national reporting upload with teams, and during that period we'll want to ensure we don't actually send the vaccination records to the imms api. Jira-Issue: MAV-2733 --- ...d_sync_to_nhs_immunisations_api_concern.rb | 12 +++++++++-- config/feature_flags.yml | 4 ++++ .../import_vaccination_records_bulk_spec.rb | 1 + ...c_to_nhs_immunisations_api_concern_spec.rb | 20 +++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb b/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb index 76b1dd1826..38adc5ddeb 100644 --- a/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb +++ b/app/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern.rb @@ -7,7 +7,11 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern scope :with_correct_source_for_nhs_immunisations_api, -> do includes(:patient).then do - it.sourced_from_service.or(it.sourced_from_bulk_upload) + if Flipper.enabled?(:sync_national_reporting_to_imms_api) + it.sourced_from_service.or(it.sourced_from_bulk_upload) + else + it.sourced_from_service + end end end @@ -34,7 +38,11 @@ module VaccinationRecordSyncToNHSImmunisationsAPIConcern end def correct_source_for_nhs_immunisations_api? - (sourced_from_service? || sourced_from_bulk_upload?) + sourced_from_service? || + ( + Flipper.enabled?(:sync_national_reporting_to_imms_api) && + sourced_from_bulk_upload? + ) end def sync_status diff --git a/config/feature_flags.yml b/config/feature_flags.yml index 16022c88d4..75304d5e20 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -43,6 +43,10 @@ reporting_api: Enables the Commissioner reporting component to authenticate to M Authorization Code Flow (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1), and retrieve statistics from /api/reporting/ +sync_national_reporting_to_imms_api: | + Sync immunisations records uploaded as part of national reporting to the NHS + Immunisations API. + testing_api: Basic API useful for automated testing. mmrv: Adds support for MMRV vaccinations diff --git a/spec/features/import_vaccination_records_bulk_spec.rb b/spec/features/import_vaccination_records_bulk_spec.rb index ce2be6cf94..439ec33cb0 100644 --- a/spec/features/import_vaccination_records_bulk_spec.rb +++ b/spec/features/import_vaccination_records_bulk_spec.rb @@ -86,6 +86,7 @@ def and_sending_to_nhs_immunisations_api_is_enabled Flipper.enable(:imms_api_integration) Flipper.enable(:imms_api_sync_job, Programme.flu) Flipper.enable(:imms_api_sync_job, Programme.hpv) + Flipper.enable(:sync_national_reporting_to_imms_api) @stubbed_post_request = stub_immunisations_api_post(Random.uuid, Random.uuid) diff --git a/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb b/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb index 00a5edb971..afe50e24e9 100644 --- a/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb +++ b/spec/models/concerns/vaccination_record_sync_to_nhs_immunisations_api_concern_spec.rb @@ -74,6 +74,8 @@ describe "with_correct_source_for_nhs_immunisations_api scope" do subject { VaccinationRecord.with_correct_source_for_nhs_immunisations_api } + before { Flipper.enable(:sync_national_reporting_to_imms_api) } + let!(:vaccination_record) do create(:vaccination_record, programme:, session:) end @@ -90,6 +92,16 @@ end it { should include(vaccination_record) } + + context "with the sync_national_reporting_to_imms_api feature flag disabled" do + before { Flipper.disable(:sync_national_reporting_to_imms_api) } + + let!(:vaccination_record) do + create(:vaccination_record, :sourced_from_bulk_upload, programme:) + end + + it { should_not include(vaccination_record) } + end end context "when vaccination record was part of a historical upload" do @@ -112,6 +124,8 @@ describe "#correct_source_to_nhs_immunisations_api?" do subject { vaccination_record.correct_source_for_nhs_immunisations_api? } + before { Flipper.enable(:sync_national_reporting_to_imms_api) } + context "when the vaccination record is eligible to sync" do it { should be true } end @@ -139,6 +153,12 @@ end it { should be true } + + context "with the sync_national_reporting_to_imms_api feature flag disabled" do + before { Flipper.disable(:sync_national_reporting_to_imms_api) } + + it { should be false } + end end context "a vaccination record created because patient is already vaccinated" do From 6064b5824e47b8b8b087d78239c446b1678c49e4 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Mon, 19 Jan 2026 11:29:10 +0000 Subject: [PATCH 51/63] Set identifier for national reporting Vaccination records uploaded through national reporting have a slightly different identifier when uploaded to the NHS Imms API. Jira-Issue: MAV-2733 --- app/lib/fhir_mapper/vaccination_record.rb | 12 +++++++++- .../fhir_mapper/vaccination_record_spec.rb | 22 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/app/lib/fhir_mapper/vaccination_record.rb b/app/lib/fhir_mapper/vaccination_record.rb index 5cf0424f74..d1dff09bf3 100644 --- a/app/lib/fhir_mapper/vaccination_record.rb +++ b/app/lib/fhir_mapper/vaccination_record.rb @@ -6,6 +6,8 @@ class VaccinationRecord MAVIS_SYSTEM_NAME = "http://manage-vaccinations-in-schools.nhs.uk/vaccination_records" + MAVIS_NATIONAL_REPORTING_SYSTEM_NAME = + "http://manage-vaccinations-in-schools.nhs.uk/national-reporting/vaccination-records" MILLILITER_SUB_STRINGS = %w[ml millilitre milliliter].freeze @@ -123,7 +125,15 @@ def self.from_fhir_record(fhir_record, patient:) private def fhir_identifier - FHIR::Identifier.new(system: MAVIS_SYSTEM_NAME, value: uuid) + case source + when "bulk_upload" + FHIR::Identifier.new( + system: MAVIS_NATIONAL_REPORTING_SYSTEM_NAME, + value: uuid + ) + else + FHIR::Identifier.new(system: MAVIS_SYSTEM_NAME, value: uuid) + end end def fhir_vaccination_procedure_extension diff --git a/spec/lib/fhir_mapper/vaccination_record_spec.rb b/spec/lib/fhir_mapper/vaccination_record_spec.rb index af2103b457..e0550a046e 100644 --- a/spec/lib/fhir_mapper/vaccination_record_spec.rb +++ b/spec/lib/fhir_mapper/vaccination_record_spec.rb @@ -88,6 +88,28 @@ its(:value) { should eq vaccination_record.uuid } its(:system) { should eq mavis_system } + + context "when the vaccination record is from national reporting" do + let(:national_reporting_test_ods_code) { "NR121" } + let(:vaccination_record) do + create( + :vaccination_record, + :sourced_from_bulk_upload, + uploaded_by: User.first, + performed_ods_code: :national_reporting_test_ods_code, + patient:, + programme:, + vaccine: programme.vaccines.first, + outcome: "administered" + ) + end + + let(:national_reporting_system) do + "http://manage-vaccinations-in-schools.nhs.uk/national-reporting/vaccination-records" + end + + its(:system) { should eq national_reporting_system } + end end describe "vaccine code" do From 4cd81de99a2a79ecb3e12d49384e516c0aa68c6d Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Mon, 19 Jan 2026 22:32:55 +0000 Subject: [PATCH 52/63] Prevent onboarding schools already assigned to other teams In 2452f9b, we added support for creating school sites via the onboarding file. However, this didn't handle schools already assigned to other teams. When a school is split into sites, all teams must use the same site structure - it's invalid for sites to belong to one team while the original location belongs to another. Until we implement proper handling for overlapping schools (not needed until July 2026), fail onboarding with a clear error when: - A school in the file already belongs to another team - A school has sites that belong to another team --- app/models/onboarding.rb | 25 ++++++++++++ spec/fixtures/files/onboarding/invalid.yaml | 5 +++ spec/models/onboarding_spec.rb | 42 ++++++++++++++++++++- spec/requests/api/testing/onboard_spec.rb | 6 +++ 4 files changed, 77 insertions(+), 1 deletion(-) diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb index 1221cb08f9..a3c7d3b8af 100644 --- a/app/models/onboarding.rb +++ b/app/models/onboarding.rb @@ -54,6 +54,7 @@ class Onboarding validates :schools, presence: true validates :clinics, presence: true validate :no_duplicate_urns_across_school_types + validate :no_schools_with_existing_team_attachments def initialize(hash) config = hash.deep_symbolize_keys @@ -154,6 +155,30 @@ def no_duplicate_urns_across_school_types end end + def no_schools_with_existing_team_attachments + schools.each_with_index do |school, index| + urn = school.urn + next if urn.blank? + + locations_with_teams = Location.school.where(urn:).joins(:teams).distinct + + next unless locations_with_teams.exists? + + site_codes = locations_with_teams.pluck(:site).compact.sort + team_names = + locations_with_teams.flat_map { it.teams.pluck(:name) }.uniq.sort + + message = + if site_codes.any? + "URN #{urn} has sites (#{site_codes.join(", ")}) already attached to teams: #{team_names.join(", ")}" + else + "URN #{urn} is already attached to teams: #{team_names.join(", ")}" + end + + errors.add("school.#{index}.urn", message) + end + end + def valid?(context = nil) ([super] + models.map(&:valid?)).all? end diff --git a/spec/fixtures/files/onboarding/invalid.yaml b/spec/fixtures/files/onboarding/invalid.yaml index d283dd4b7b..c300d6cd5c 100644 --- a/spec/fixtures/files/onboarding/invalid.yaml +++ b/spec/fixtures/files/onboarding/invalid.yaml @@ -18,3 +18,8 @@ schools: address_line_1: "456 High St" address_town: "London" address_postcode: "SW1A 1AA" + - 111111 + - 222222 + - urn: 555555 + site: "A" + name: "Existing School 5 (Site A)" diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb index 98dd4b3a56..38a0637a1d 100644 --- a/spec/models/onboarding_spec.rb +++ b/spec/models/onboarding_spec.rb @@ -75,6 +75,38 @@ context "with an invalid configuration file" do let(:filename) { "onboarding/invalid.yaml" } + let(:team) { create(:team, name: "Existing Team") } + + before do + create(:school, :secondary, :open, urn: "111111", team:) + create(:school, :secondary, :open, urn: "555555", team:) + create( + :school, + :secondary, + :open, + urn: "222222", + name: "School with no site" + ) + create( + :school, + :secondary, + :open, + urn: "222222", + site: "A", + name: "School with Site A", + team: + ) + create( + :school, + :secondary, + :open, + urn: "222222", + site: "B", + name: "School with Site B", + team: + ) + end + it { should be_invalid } it "has errors" do @@ -82,7 +114,6 @@ expect(onboarding.errors.messages).to eq( { - "organisation.ods_code": ["can't be blank"], "team.careplus_venue_code": ["can't be blank"], "team.name": ["can't be blank"], "team.phone": ["can't be blank", "is invalid"], @@ -92,6 +123,15 @@ "team.workgroup": ["can't be blank"], "school.0.subteam": ["can't be blank"], "school.1.subteam": ["can't be blank"], + "school.5.urn": [ + "URN 111111 is already attached to teams: Existing Team" + ], + "school.6.urn": [ + "URN 222222 has sites (A, B) already attached to teams: Existing Team" + ], + "school.7.urn": [ + "URN 555555 is already attached to teams: Existing Team" + ], schools: [ "URN(s) 456789 cannot appear as both a regular school and a site" ], diff --git a/spec/requests/api/testing/onboard_spec.rb b/spec/requests/api/testing/onboard_spec.rb index 53a134ff68..2c0c3cc16e 100644 --- a/spec/requests/api/testing/onboard_spec.rb +++ b/spec/requests/api/testing/onboard_spec.rb @@ -71,6 +71,12 @@ "school.3.status" => ["is not included in the list"], "school.4.location" => ["can't be blank"], "school.4.status" => ["is not included in the list"], + "school.5.location" => ["can't be blank"], + "school.5.status" => ["is not included in the list"], + "school.6.location" => ["can't be blank"], + "school.6.status" => ["is not included in the list"], + "school.7.location" => ["can't be blank"], + "school.7.status" => ["is not included in the list"], "schools" => [ "URN(s) 456789 cannot appear as both a regular school and a site" ], From 7a082d543a9158788a6f9a38ed3e447bede2e856 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:46:49 +0000 Subject: [PATCH 53/63] Bump aws-sdk-ecr from 1.118.0 to 1.119.0 Bumps [aws-sdk-ecr](https://github.com/aws/aws-sdk-ruby) from 1.118.0 to 1.119.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-ecr/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-ecr dependency-version: 1.119.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a304f79358..17913f4849 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -151,8 +151,8 @@ GEM aws-sdk-ec2 (1.591.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-ecr (1.118.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-ecr (1.119.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-iam (1.140.0) aws-sdk-core (~> 3, >= 3.241.4) From 7455bea00f42b6c151f20d12b3ca5141ea00b073 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:46:51 +0000 Subject: [PATCH 54/63] Bump nhsuk-frontend from 10.3.0 to 10.3.1 Bumps [nhsuk-frontend](https://github.com/nhsuk/nhsuk-frontend) from 10.3.0 to 10.3.1. - [Release notes](https://github.com/nhsuk/nhsuk-frontend/releases) - [Changelog](https://github.com/nhsuk/nhsuk-frontend/blob/main/CHANGELOG.md) - [Commits](https://github.com/nhsuk/nhsuk-frontend/compare/v10.3.0...v10.3.1) --- updated-dependencies: - dependency-name: nhsuk-frontend dependency-version: 10.3.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 144f4b3ca0..748949df83 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "accessible-autocomplete": "^3.0.1", "esbuild": "^0.27.2", "idb": "^8.0.3", - "nhsuk-frontend": "^10.3.0", + "nhsuk-frontend": "^10.3.1", "sass": "^1.97.2" }, "scripts": { diff --git a/yarn.lock b/yarn.lock index ec03329629..2501fd1ab4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3533,10 +3533,10 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -nhsuk-frontend@^10.3.0: - version "10.3.0" - resolved "https://registry.yarnpkg.com/nhsuk-frontend/-/nhsuk-frontend-10.3.0.tgz#01f728f994207da533279102920ef94f40eab3af" - integrity sha512-LPObcaPFZDcNNhi/gaK4MDtXppaDR/vwi3YhqzN7v+H15IZqjzZuA4nbh9CTiUlIggVT/OEKeUT5E/sGnGH/jw== +nhsuk-frontend@^10.3.1: + version "10.3.1" + resolved "https://registry.yarnpkg.com/nhsuk-frontend/-/nhsuk-frontend-10.3.1.tgz#154249e1d1cc3e4e043f645242a36bba0b3975b8" + integrity sha512-R9DH31TfTA3fgi3U0jSVO6wxXEAQY9j5pYzRF4lB3M/Kx3UxvN+oCCWRjTKC/L6wqcddX3qzByl5wKe3PdNX4Q== nice-try@^1.0.4: version "1.0.5" From 083b5eb25a1f569dc835235797cf2778b95533d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:47:23 +0000 Subject: [PATCH 55/63] Bump solargraph from 0.58.1 to 0.58.2 Bumps [solargraph](https://github.com/castwide/solargraph) from 0.58.1 to 0.58.2. - [Changelog](https://github.com/castwide/solargraph/blob/master/CHANGELOG.md) - [Commits](https://github.com/castwide/solargraph/compare/v0.58.1...v0.58.2) --- updated-dependencies: - dependency-name: solargraph dependency-version: 0.58.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a304f79358..4d2329fd53 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -355,8 +355,8 @@ GEM pg (>= 0.18.1) jwt (3.1.2) base64 - kramdown (2.5.1) - rexml (>= 3.3.9) + kramdown (2.5.2) + rexml (>= 3.4.4) kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.5) @@ -688,7 +688,7 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - solargraph (0.58.1) + solargraph (0.58.2) ast (~> 2.4.3) backport (~> 1.2) benchmark (~> 0.4) From 0786bcc05ce95743a680ed0633c684fc8e001067 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:47:56 +0000 Subject: [PATCH 56/63] Bump aws-sdk-s3 from 1.211.0 to 1.212.0 Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.211.0 to 1.212.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-s3/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-s3 dependency-version: 1.212.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index a304f79358..f3593bf78b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -157,14 +157,14 @@ GEM aws-sdk-iam (1.140.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-kms (1.120.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-kms (1.121.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) aws-sdk-rds (1.306.0) aws-sdk-core (~> 3, >= 3.241.4) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.211.0) - aws-sdk-core (~> 3, >= 3.241.3) + aws-sdk-s3 (1.212.0) + aws-sdk-core (~> 3, >= 3.241.4) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) From da1395f6af7922caf5ebc533a236c8f1fbeda0e9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 19 Jan 2026 17:37:52 +0000 Subject: [PATCH 57/63] Track location on programme status This updates the `Patient::ProgrammeStatus` model to include an optional location association that works the same as the `Patient::VaccinationStatus` `latest_location` association. We need this to be able to fully replace all the usage of the `Patient::VaccinationStatus` model with the `Patient::ProgrammeStatus` and replicate the logic related to eligiblity of a session depending on where the patient was vaccinated. Jira-Issue: MAV-2660 --- app/lib/status_generator/programme.rb | 4 + app/lib/status_updater.rb | 3 +- app/models/patient/programme_status.rb | 6 +- ..._location_to_patient_programme_statuses.rb | 7 ++ db/schema.rb | 4 +- spec/factories/patient_programme_statuses.rb | 2 + spec/lib/status_generator/programme_spec.rb | 82 +++++++++++++------ spec/models/patient/programme_status_spec.rb | 4 + 8 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 db/migrate/20260119161311_add_location_to_patient_programme_statuses.rb diff --git a/app/lib/status_generator/programme.rb b/app/lib/status_generator/programme.rb index 2ce6fb18ee..58d8fbdcd6 100644 --- a/app/lib/status_generator/programme.rb +++ b/app/lib/status_generator/programme.rb @@ -116,6 +116,10 @@ def date vaccination_generator.latest_date end + def location_id + vaccination_generator.latest_location_id + end + private attr_reader :programme, diff --git a/app/lib/status_updater.rb b/app/lib/status_updater.rb index ba842203dd..9015370bdc 100644 --- a/app/lib/status_updater.rb +++ b/app/lib/status_updater.rb @@ -73,11 +73,12 @@ def update_programme_statuses! conflict_target: [:id], columns: %i[ date + disease_types dose_sequence + location_id status vaccine_methods without_gelatine - disease_types ] } ) diff --git a/app/models/patient/programme_status.rb b/app/models/patient/programme_status.rb index 97fe4c5999..7a684c9ec3 100644 --- a/app/models/patient/programme_status.rb +++ b/app/models/patient/programme_status.rb @@ -13,12 +13,14 @@ # status :integer default("not_eligible"), not null # vaccine_methods :integer is an Array # without_gelatine :boolean +# location_id :bigint # patient_id :bigint not null # # Indexes # # idx_on_academic_year_patient_id_3d5bf8d2c8 (academic_year,patient_id) # idx_on_patient_id_academic_year_programme_type_75e0e0c471 (patient_id,academic_year,programme_type) UNIQUE +# index_patient_programme_statuses_on_location_id (location_id) # index_patient_programme_statuses_on_patient_id (patient_id) # index_patient_programme_statuses_on_status (status) # @@ -31,6 +33,7 @@ class Patient::ProgrammeStatus < ApplicationRecord include HasVaccineMethods belongs_to :patient + belongs_to :location, optional: true has_many :patient_locations, -> { includes(location: :location_programme_year_groups) }, @@ -134,11 +137,12 @@ def group = GROUPS.find { status.starts_with?(it) } def assign self.date = generator.date + self.disease_types = generator.disease_types self.dose_sequence = generator.dose_sequence + self.location_id = generator.location_id self.status = generator.status self.vaccine_methods = generator.vaccine_methods self.without_gelatine = generator.without_gelatine - self.disease_types = generator.disease_types end def vaccine_criteria = VaccineCriteria.from_programme_status(self) diff --git a/db/migrate/20260119161311_add_location_to_patient_programme_statuses.rb b/db/migrate/20260119161311_add_location_to_patient_programme_statuses.rb new file mode 100644 index 0000000000..79d37f11e0 --- /dev/null +++ b/db/migrate/20260119161311_add_location_to_patient_programme_statuses.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLocationToPatientProgrammeStatuses < ActiveRecord::Migration[8.1] + def change + add_reference :patient_programme_statuses, :location + end +end diff --git a/db/schema.rb b/db/schema.rb index dbb502dbb1..faf1b005ab 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_01_15_090835) do +ActiveRecord::Schema[8.1].define(version: 2026_01_19_161311) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -621,12 +621,14 @@ t.date "date" t.enum "disease_types", array: true, enum_type: "disease_type" t.integer "dose_sequence" + t.bigint "location_id" t.bigint "patient_id", null: false t.enum "programme_type", null: false, enum_type: "programme_type" t.integer "status", default: 0, null: false t.integer "vaccine_methods", array: true t.boolean "without_gelatine" t.index ["academic_year", "patient_id"], name: "idx_on_academic_year_patient_id_3d5bf8d2c8" + t.index ["location_id"], name: "index_patient_programme_statuses_on_location_id" t.index ["patient_id", "academic_year", "programme_type"], name: "idx_on_patient_id_academic_year_programme_type_75e0e0c471", unique: true t.index ["patient_id"], name: "index_patient_programme_statuses_on_patient_id" t.index ["status"], name: "index_patient_programme_statuses_on_status" diff --git a/spec/factories/patient_programme_statuses.rb b/spec/factories/patient_programme_statuses.rb index 1fc44964ff..08320bff11 100644 --- a/spec/factories/patient_programme_statuses.rb +++ b/spec/factories/patient_programme_statuses.rb @@ -13,12 +13,14 @@ # status :integer default("not_eligible"), not null # vaccine_methods :integer is an Array # without_gelatine :boolean +# location_id :bigint # patient_id :bigint not null # # Indexes # # idx_on_academic_year_patient_id_3d5bf8d2c8 (academic_year,patient_id) # idx_on_patient_id_academic_year_programme_type_75e0e0c471 (patient_id,academic_year,programme_type) UNIQUE +# index_patient_programme_statuses_on_location_id (location_id) # index_patient_programme_statuses_on_patient_id (patient_id) # index_patient_programme_statuses_on_status (status) # diff --git a/spec/lib/status_generator/programme_spec.rb b/spec/lib/status_generator/programme_spec.rb index 92a20869f9..54bacea8d1 100644 --- a/spec/lib/status_generator/programme_spec.rb +++ b/spec/lib/status_generator/programme_spec.rb @@ -21,18 +21,20 @@ let(:programme) { Programme.sample } let(:session) { create(:session, programmes: [programme]) } let(:patient) { create(:patient, session:) } + let(:location) { create(:school) } context "when already vaccinated" do let(:programme) { Programme.hpv } let!(:vaccination_record) do - create(:vaccination_record, :already_had, patient:, programme:) + create(:vaccination_record, :already_had, patient:, programme:, location:) end - its(:status) { should be(:vaccinated_already) } its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should eq(%w[human_papillomavirus]) } its(:dose_sequence) { should be_nil } + its(:location_id) { should eq(location.id) } + its(:status) { should be(:vaccinated_already) } its(:vaccine_methods) { should be_empty } its(:without_gelatine) { should be_nil } end @@ -41,13 +43,14 @@ let(:programme) { Programme.hpv } let!(:vaccination_record) do - create(:vaccination_record, patient:, programme:) + create(:vaccination_record, patient:, programme:, location:) end - its(:status) { should be(:vaccinated_fully) } its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should eq(%w[human_papillomavirus]) } its(:dose_sequence) { should be_nil } + its(:location_id) { should eq(location.id) } + its(:status) { should be(:vaccinated_fully) } its(:vaccine_methods) { should be_empty } its(:without_gelatine) { should be_nil } end @@ -56,13 +59,14 @@ let(:programme) { Programme.mmr } let!(:vaccination_record) do - create(:vaccination_record, patient:, programme:) + create(:vaccination_record, patient:, programme:, location:) end - its(:status) { should be(:needs_consent_no_response) } its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should be_nil } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:needs_consent_no_response) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } @@ -81,22 +85,31 @@ before { create(:consent, :given, patient:, programme:) } let!(:vaccination_record) do - create(:vaccination_record, :unwell, patient:, programme:) + create(:vaccination_record, :unwell, patient:, programme:, location:) end - its(:status) { should be(:cannot_vaccinate_unwell) } its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should eq(programme.disease_types) } + its(:location_id) { should be_nil } + its(:status) { should be(:cannot_vaccinate_unwell) } its(:vaccine_methods) { should contain_exactly("injection") } its(:without_gelatine) { should be(false) } context "on a different day" do let!(:vaccination_record) do - create(:vaccination_record, :unwell, :yesterday, patient:, programme:) + create( + :vaccination_record, + :unwell, + :yesterday, + patient:, + programme:, + location: + ) end - its(:status) { should be(:due) } its(:date) { should eq(vaccination_record.performed_at.to_date) } + its(:location_id) { should be_nil } + its(:status) { should be(:due) } end end @@ -104,12 +117,13 @@ before { create(:consent, :given, patient:, programme:) } let!(:vaccination_record) do - create(:vaccination_record, :refused, patient:, programme:) + create(:vaccination_record, :refused, patient:, programme:, location:) end - its(:status) { should be(:cannot_vaccinate_refused) } its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should eq(programme.disease_types) } + its(:location_id) { should be_nil } + its(:status) { should be(:cannot_vaccinate_refused) } its(:vaccine_methods) { should contain_exactly("injection") } its(:without_gelatine) { should be(false) } @@ -118,8 +132,9 @@ create(:vaccination_record, :unwell, :yesterday, patient:, programme:) end - its(:status) { should be(:due) } its(:date) { should eq(vaccination_record.performed_at.to_date) } + its(:location_id) { should be_nil } + its(:status) { should be(:due) } end end @@ -127,12 +142,19 @@ before { create(:consent, :given, patient:, programme:) } let!(:vaccination_record) do - create(:vaccination_record, :contraindicated, patient:, programme:) + create( + :vaccination_record, + :contraindicated, + patient:, + programme:, + location: + ) end - its(:status) { should be(:cannot_vaccinate_contraindicated) } its(:date) { should eq(vaccination_record.performed_at.to_date) } its(:disease_types) { should eq(programme.disease_types) } + its(:location_id) { should be_nil } + its(:status) { should be(:cannot_vaccinate_contraindicated) } its(:vaccine_methods) { should contain_exactly("injection") } its(:without_gelatine) { should be(false) } @@ -141,8 +163,9 @@ create(:vaccination_record, :unwell, :yesterday, patient:, programme:) end - its(:status) { should be(:due) } its(:date) { should eq(vaccination_record.performed_at.to_date) } + its(:location_id) { should be_nil } + its(:status) { should be(:due) } end end @@ -153,9 +176,10 @@ create(:attendance_record, :absent, patient:, session:) end - its(:status) { should be(:cannot_vaccinate_absent) } its(:date) { should eq(attendance_record.date) } its(:disease_types) { should eq(programme.disease_types) } + its(:location_id) { should be_nil } + its(:status) { should be(:cannot_vaccinate_absent) } its(:vaccine_methods) { should contain_exactly("injection") } its(:without_gelatine) { should be(false) } @@ -181,10 +205,11 @@ ) end - its(:status) { should be(:cannot_vaccinate_delay_vaccination) } its(:date) { should eq(Date.tomorrow) } its(:disease_types) { should eq(programme.disease_types) } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:cannot_vaccinate_delay_vaccination) } its(:vaccine_methods) { should contain_exactly("injection") } its(:without_gelatine) { should be(false) } end @@ -195,10 +220,11 @@ create(:triage, :invite_to_clinic, patient:, programme:) end - its(:status) { should be(:needs_triage) } its(:date) { should be_nil } its(:disease_types) { should eq(programme.disease_types) } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:needs_triage) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end @@ -209,10 +235,11 @@ create(:triage, :do_not_vaccinate, patient:, programme:) end - its(:status) { should be(:cannot_vaccinate_do_not_vaccinate) } its(:date) { should be_nil } its(:disease_types) { should eq(programme.disease_types) } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:cannot_vaccinate_do_not_vaccinate) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end @@ -220,10 +247,11 @@ context "when needs triage" do before { create(:consent, :given, :needing_triage, patient:, programme:) } - its(:status) { should be(:needs_triage) } its(:date) { should be_nil } its(:disease_types) { should eq(programme.disease_types) } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:needs_triage) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end @@ -231,10 +259,11 @@ context "when consent is refused" do before { create(:consent, :refused, patient:, programme:) } - its(:status) { should be(:has_refusal_consent_refused) } its(:date) { should be_nil } its(:disease_types) { should be_empty } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:has_refusal_consent_refused) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end @@ -245,19 +274,21 @@ create(:consent, :given, patient:, programme:, parent: create(:parent)) end - its(:status) { should be(:has_refusal_consent_conflicts) } its(:date) { should be_nil } its(:disease_types) { should be_empty } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:has_refusal_consent_conflicts) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end context "when consent is needed" do - its(:status) { should be(:needs_consent_no_response) } its(:date) { should be_nil } its(:disease_types) { should be_nil } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:needs_consent_no_response) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end @@ -265,10 +296,11 @@ context "when not eligible" do let(:patient) { create(:patient, year_group: 20) } - its(:status) { should be(:not_eligible) } its(:date) { should be_nil } its(:disease_types) { should be_nil } its(:dose_sequence) { should be_nil } + its(:location_id) { should be_nil } + its(:status) { should be(:not_eligible) } its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } end diff --git a/spec/models/patient/programme_status_spec.rb b/spec/models/patient/programme_status_spec.rb index bc8d673ee7..360281b233 100644 --- a/spec/models/patient/programme_status_spec.rb +++ b/spec/models/patient/programme_status_spec.rb @@ -13,12 +13,14 @@ # status :integer default("not_eligible"), not null # vaccine_methods :integer is an Array # without_gelatine :boolean +# location_id :bigint # patient_id :bigint not null # # Indexes # # idx_on_academic_year_patient_id_3d5bf8d2c8 (academic_year,patient_id) # idx_on_patient_id_academic_year_programme_type_75e0e0c471 (patient_id,academic_year,programme_type) UNIQUE +# index_patient_programme_statuses_on_location_id (location_id) # index_patient_programme_statuses_on_patient_id (patient_id) # index_patient_programme_statuses_on_status (status) # @@ -46,6 +48,7 @@ date: Date.new(2020, 1, 1), disease_types: %w[influenza], dose_sequence: 1, + location_id: 1, status: "vaccinated", vaccine_methods: %w[injection], without_gelatine: true @@ -58,6 +61,7 @@ expect(patient_programme_status.date).to eq(Date.new(2020, 1, 1)) expect(patient_programme_status.disease_types).to eq(%w[influenza]) expect(patient_programme_status.dose_sequence).to eq(1) + expect(patient_programme_status.location_id).to eq(1) expect(patient_programme_status.status).to eq("vaccinated") expect(patient_programme_status.vaccine_methods).to eq(%w[injection]) expect(patient_programme_status.without_gelatine).to be(true) From b835450e9c18d7b116260cb6e8c5a11c8cb92576 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Tue, 20 Jan 2026 10:28:01 +0000 Subject: [PATCH 58/63] Change ODS code for support users We aren't able to use X26 in production, so we're using this one instead. Jira-Issue: MAV-2756 --- app/models/cis2_info.rb | 2 +- spec/factories/users.rb | 2 +- spec/features/inspect_graph_pii_access_logging_spec.rb | 2 +- spec/features/inspect_timeline_pii_access_logging_spec.rb | 2 +- .../user_cis2_authentication_inspect_support_no_pii_spec.rb | 2 +- .../user_cis2_authentication_inspect_support_with_pii_spec.rb | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/cis2_info.rb b/app/models/cis2_info.rb index cf989ee055..194733efd1 100644 --- a/app/models/cis2_info.rb +++ b/app/models/cis2_info.rb @@ -8,7 +8,7 @@ class CIS2Info SUPPORT_ROLE = "S8001:G8005:R8015" SUPPORT_WORKGROUP = "mavissupport" - SUPPORT_ORGANISATION = "X26" + SUPPORT_ORGANISATION = "Y90128" ACCESS_SENSITIVE_FLAGGED_RECORDS_ACTIVITY_CODE = "B1611" INDEPENDENT_PRESCRIBING_ACTIVITY_CODE = "B0420" diff --git a/spec/factories/users.rb b/spec/factories/users.rb index dfa3e51748..002f191543 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -118,7 +118,7 @@ end trait :support do - team { create(:team, ods_code: "X26") } + team { create(:team, ods_code: "Y90128") } role_code { CIS2Info::SUPPORT_ROLE } sequence(:email) { |n| "support-#{n}@example.com" } role_workgroups { [CIS2Info::SUPPORT_WORKGROUP] } diff --git a/spec/features/inspect_graph_pii_access_logging_spec.rb b/spec/features/inspect_graph_pii_access_logging_spec.rb index d6f6bd5cb5..cb75d6ee2c 100644 --- a/spec/features/inspect_graph_pii_access_logging_spec.rb +++ b/spec/features/inspect_graph_pii_access_logging_spec.rb @@ -38,7 +38,7 @@ # Setup methods def prepare_support_organisation_with_pii_access - @organisation_support = create(:organisation, ods_code: "X26") + @organisation_support = create(:organisation, ods_code: "Y90128") @team_support = create( :team, diff --git a/spec/features/inspect_timeline_pii_access_logging_spec.rb b/spec/features/inspect_timeline_pii_access_logging_spec.rb index ad4cb102ba..7b563c89c2 100644 --- a/spec/features/inspect_timeline_pii_access_logging_spec.rb +++ b/spec/features/inspect_timeline_pii_access_logging_spec.rb @@ -26,7 +26,7 @@ # Setup methods def prepare_support_organisation_with_pii_access - @organisation_support = create(:organisation, ods_code: "X26") + @organisation_support = create(:organisation, ods_code: "Y90128") @team_support = create( :team, diff --git a/spec/features/user_cis2_authentication_inspect_support_no_pii_spec.rb b/spec/features/user_cis2_authentication_inspect_support_no_pii_spec.rb index 2db47babf9..aa5f8f322d 100644 --- a/spec/features/user_cis2_authentication_inspect_support_no_pii_spec.rb +++ b/spec/features/user_cis2_authentication_inspect_support_no_pii_spec.rb @@ -58,7 +58,7 @@ def given_ops_tools_feature_flag_is_off end def given_a_test_support_organisation_is_setup_in_mavis_and_cis2 - @ods_code = "X26" + @ods_code = "Y90128" @team_support = create(:team, ods_code: @ods_code, workgroup: CIS2Info::SUPPORT_WORKGROUP) @user = create(:user, :support, team: @team_support) diff --git a/spec/features/user_cis2_authentication_inspect_support_with_pii_spec.rb b/spec/features/user_cis2_authentication_inspect_support_with_pii_spec.rb index e181fca599..79e35a23c2 100644 --- a/spec/features/user_cis2_authentication_inspect_support_with_pii_spec.rb +++ b/spec/features/user_cis2_authentication_inspect_support_with_pii_spec.rb @@ -56,7 +56,7 @@ def given_ops_tools_feature_flag_is_off end def given_a_test_ops_organisation_with_pii_is_setup_in_mavis_and_cis2 - @organisation_support = create(:organisation, ods_code: "X26") + @organisation_support = create(:organisation, ods_code: "Y90128") @team_support = create( :team, From 173d1500f2b43a7362db896397ec04300c6744a3 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Tue, 20 Jan 2026 11:40:03 +0000 Subject: [PATCH 59/63] Document school sites in onboarding configuration Update managing teams documentation to include the site object format for schools with multiple physical locations. Add notes on validation rules for sites and team assignment restrictions. --- docs/managing-teams.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/managing-teams.md b/docs/managing-teams.md index ae07df45a3..27c825a3eb 100644 --- a/docs/managing-teams.md +++ b/docs/managing-teams.md @@ -31,7 +31,18 @@ subteams: reply_to_id: # Optional GOV.UK Notify Reply-To UUID schools: - subteam1: [] # URNs managed by a particular team + subteam1: + - 123456 # Simple URN for a school without sites + - urn: 234567 # URN for a school with multiple sites + site: "A" # Site code (A, B, C, etc.) + name: "School Name (Site A)" # Unique name for this site + - urn: 234567 + site: "B" + name: "School Name (Site B)" + address_line_1: "123 High St" # Optional: override GIAS address + address_line_2: "Floor 2" + address_town: "London" + address_postcode: "SW1A 1AA" clinics: subteam1: @@ -46,6 +57,23 @@ clinics: [config-onboarding]: /config/onboarding +### Schools and sites + +Schools can be added in two ways: + +- Simple URN: Just the URN number (e.g., 123456) for schools without multiple sites +- Site object: An object with urn, site, and name for schools that have been split into multiple physical locations + +When adding sites: + +- urn: The URN of the parent school (must exist in GIAS) +- site: A unique site code (typically A, B, C, etc.) +- name: A unique name for this site (cannot match existing school/site names) + +Address fields are optional and will inherit from the parent school if not provided + +Note: Schools or sites that are already assigned to another team cannot be onboarded in this way. Thy will have to be added manually (see command below). If a school has been split into sites for one team, it must use the same site structure for all teams. + ### Command Once the file has been written you can use the `onboard` command to set everything up in the service. From e2e54416e8c7fbb2f6d0a1b53ad5348bf18f77b3 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Tue, 20 Jan 2026 13:08:46 +0000 Subject: [PATCH 60/63] Hide "Change" vaccinator and dose sequence buttons for PoC users This should have been included as part of PRs: #5670 and #5672 --- app/views/draft_vaccination_records/confirm.html.erb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/draft_vaccination_records/confirm.html.erb b/app/views/draft_vaccination_records/confirm.html.erb index 6eb8b0dd6f..26cc146061 100644 --- a/app/views/draft_vaccination_records/confirm.html.erb +++ b/app/views/draft_vaccination_records/confirm.html.erb @@ -18,7 +18,7 @@ batch: wizard_path("batch"), delivery_method: wizard_path("delivery"), delivery_site: wizard_path("delivery"), - dose_sequence: wizard_path("dose-sequence"), + dose_sequence: @draft_vaccination_record.wizard_steps.include?(:dose_sequence) ? wizard_path("dose-sequence") : nil, dose_volume: @draft_vaccination_record.wizard_steps.include?(:dose) ? wizard_path("dose") : nil, identity: wizard_path("identity"), supplier: wizard_path("supplier"), @@ -26,7 +26,7 @@ notes: wizard_path("notes"), outcome: @draft_vaccination_record.wizard_steps.include?(:outcome) ? wizard_path("outcome") : nil, performed_at: wizard_path("date-and-time"), - vaccinator: wizard_path("vaccinator"), + vaccinator: @draft_vaccination_record.wizard_steps.include?(:vaccinator) ? wizard_path("vaccinator") : nil, } %> <% show_notes = @draft_vaccination_record.editing? %> From 54079be11b267c54c4c326319ce07afe49b565c8 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Tue, 20 Jan 2026 13:22:07 +0000 Subject: [PATCH 61/63] Filter schools when editing location on NR records When using all the schools in Mavis, there were severe performance issues on this page; both loading the page, and also searching in the dropdown list. This reduces the scope from 52167 to 27131 schools, which may make the performance manageable. --- .../draft_vaccination_records_controller.rb | 2 +- app/views/draft_vaccination_records/location.html.erb | 10 +++++----- spec/features/edit_vaccination_record_spec.rb | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/controllers/draft_vaccination_records_controller.rb b/app/controllers/draft_vaccination_records_controller.rb index 04ccc3fab1..2195bf39ed 100644 --- a/app/controllers/draft_vaccination_records_controller.rb +++ b/app/controllers/draft_vaccination_records_controller.rb @@ -244,7 +244,7 @@ def set_batches def set_locations @locations = if @draft_vaccination_record.bulk_upload_user_and_record? - Location.school.order(:name) + Location.school.where(status: "open").order(:name) else policy_scope(Location).community_clinic end diff --git a/app/views/draft_vaccination_records/location.html.erb b/app/views/draft_vaccination_records/location.html.erb index 202f271d46..786d323f28 100644 --- a/app/views/draft_vaccination_records/location.html.erb +++ b/app/views/draft_vaccination_records/location.html.erb @@ -14,11 +14,11 @@ caption: { text: @patient.full_name }, data: { module: "app-autocomplete" } do %> - <% @locations.find_each do |school| %> - <%= tag.option location_display_name(school, show_urn: true), - value: school.id, - selected: school.id == @draft_vaccination_record.location&.id, - data: { hint: format_address_single_line(school) } %> + <% @locations.find_each do |location| %> + <%= tag.option location_display_name(location, show_urn: true), + value: location.id, + selected: location.id == @draft_vaccination_record.location&.id, + data: { hint: format_address_single_line(location) } %> <% end %> <%= tag.option "Unknown", value: "unknown", selected: @draft_vaccination_record.location.nil? %> <% end %> diff --git a/spec/features/edit_vaccination_record_spec.rb b/spec/features/edit_vaccination_record_spec.rb index b2ef59299d..ec68cd6bfe 100644 --- a/spec/features/edit_vaccination_record_spec.rb +++ b/spec/features/edit_vaccination_record_spec.rb @@ -456,7 +456,7 @@ def given_a_bulk_upload_team_exists year_group: 8 ) - @school = create(:school, name: "A New School") + @school = create(:school, name: "A New School", status: "open") @vaccine = @programme.vaccines.first From c4e8cd004e99591a9b4ebc917f9622f99f118f6d Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Tue, 20 Jan 2026 14:23:23 +0000 Subject: [PATCH 62/63] Rename functional tests to end-to-end tests --- .github/workflows/end-to-end-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index fd70012d51..6032ee373d 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -329,10 +329,10 @@ jobs: fi env: BRANCH_TO_CHECK: ${{ github.head_ref }} - call-functional-tests: + call-end-to-end-tests: needs: [ launch-dockerized-devimage, wait-for-task-stability, find-correct-test-branch ] if: ${{ !cancelled() && needs.launch-dockerized-devimage.result == 'success' && needs.wait-for-task-stability.result == 'success'}} - uses: NHSDigital/manage-vaccinations-in-schools-testing/.github/workflows/functional_selected_device.yaml@main + uses: NHSDigital/manage-vaccinations-in-schools-testing/.github/workflows/end-to-end-tests.yaml@main permissions: contents: write with: @@ -343,7 +343,7 @@ jobs: HTTP_AUTH_TOKEN_FOR_TESTS: ${{ secrets.HTTP_AUTH_TOKEN_FOR_TESTS }} MAVIS_TESTING_REPO_ACCESS_TOKEN: ${{ secrets.MAVIS_TESTING_REPO_ACCESS_TOKEN }} stop-docker-environment: - needs: [ call-functional-tests, launch-dockerized-devimage, wait-for-task-stability ] + needs: [ call-end-to-end-tests, launch-dockerized-devimage, wait-for-task-stability ] if: ${{ always() && needs.launch-dockerized-devimage.result != 'skipped'}} runs-on: ubuntu-latest permissions: From 0e4a15f0f60868dddf35ad0beab10a868d00d216 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Tue, 20 Jan 2026 13:03:22 +0000 Subject: [PATCH 63/63] Don't show programme status row if there are no statuses If there are no statuses to show on the child's search card, then remove the entire "Programme status" row --- app/components/app_patient_search_result_card_component.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/app_patient_search_result_card_component.rb b/app/components/app_patient_search_result_card_component.rb index 91fd2a6ff5..4d7e7ae39c 100644 --- a/app/components/app_patient_search_result_card_component.rb +++ b/app/components/app_patient_search_result_card_component.rb @@ -105,8 +105,6 @@ def call to: :helpers def programme_status_tag - return if programmes.empty? - status_by_programme = programmes.each_with_object({}) do |programme, hash| resolved_status = @@ -119,6 +117,8 @@ def programme_status_tag hash[resolved_status.fetch(:prefix)] = resolved_status end + return if status_by_programme.empty? + render AppAttachedTagsComponent.new(status_by_programme) end