From 29619a890688c05eb4aac328d731ff93abdc6d54 Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Wed, 8 Apr 2026 16:58:07 +0100 Subject: [PATCH 01/74] Add controller, policy and views for CarePlus exports and Export details Jira-Issue: MAV-5325 --- .../careplus_reports_controller.rb | 50 +++++++++ app/helpers/careplus_reports_helper.rb | 10 ++ app/policies/careplus_report_policy.rb | 13 +++ app/views/careplus_reports/index.html.erb | 65 +++++++++++ app/views/careplus_reports/show.html.erb | 104 ++++++++++++++++++ config/locales/en.yml | 34 ++++++ config/locales/status.en.yml | 11 ++ config/routes.rb | 7 ++ 8 files changed, 294 insertions(+) create mode 100644 app/controllers/careplus_reports_controller.rb create mode 100644 app/helpers/careplus_reports_helper.rb create mode 100644 app/policies/careplus_report_policy.rb create mode 100644 app/views/careplus_reports/index.html.erb create mode 100644 app/views/careplus_reports/show.html.erb diff --git a/app/controllers/careplus_reports_controller.rb b/app/controllers/careplus_reports_controller.rb new file mode 100644 index 0000000000..431f847555 --- /dev/null +++ b/app/controllers/careplus_reports_controller.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class CareplusReportsController < ApplicationController + include Pagy::Backend + + layout "full" + + before_action :set_careplus_report, only: %i[show download] + + def index + authorize CareplusReport + scope = policy_scope(CareplusReport).order(created_at: :desc) + @pagy, @careplus_reports = pagy(scope) + @careplus_report_records_count_by_report_id = + CareplusReportVaccinationRecord + .where(careplus_report_id: @careplus_reports.select(:id)) + .group(:careplus_report_id) + .count + end + + def show + vaccination_records = + @careplus_report + .vaccination_records + .includes(patient: :school) + .order("patients.family_name, patients.given_name") + @pagy, @vaccination_records = pagy(vaccination_records) + end + + def download + if @careplus_report.csv_data.blank? + redirect_to careplus_report_path(@careplus_report), + flash: { + error: t(".no_file") + } + return + end + + send_data @careplus_report.csv_data, + filename: @careplus_report.csv_filename, + type: "text/csv", + disposition: "attachment" + end + + private + + def set_careplus_report + @careplus_report = authorize(policy_scope(CareplusReport).find(params[:id])) + end +end diff --git a/app/helpers/careplus_reports_helper.rb b/app/helpers/careplus_reports_helper.rb new file mode 100644 index 0000000000..68bec0ff05 --- /dev/null +++ b/app/helpers/careplus_reports_helper.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module CareplusReportsHelper + def careplus_report_status_tag(careplus_report) + render AppStatusTagComponent.new( + careplus_report.status, + context: :careplus_report + ) + end +end diff --git a/app/policies/careplus_report_policy.rb b/app/policies/careplus_report_policy.rb new file mode 100644 index 0000000000..47ebdfd650 --- /dev/null +++ b/app/policies/careplus_report_policy.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CareplusReportPolicy < ApplicationPolicy + def index? = team.has_point_of_care_access? + + def show? = team.has_point_of_care_access? + + def download? = show? + + class Scope < ApplicationPolicy::Scope + def resolve = scope.where(team:) + end +end diff --git a/app/views/careplus_reports/index.html.erb b/app/views/careplus_reports/index.html.erb new file mode 100644 index 0000000000..75ad127100 --- /dev/null +++ b/app/views/careplus_reports/index.html.erb @@ -0,0 +1,65 @@ +<% content_for :navigation do %> + <%= govuk_back_link(href: reports_path) %> +<% end %> + +

+ <%= content_for(:page_title, t(".title")); t(".title") %> +

+ +

<%= t(".description") %>

+ +<% if @careplus_reports.any? %> + <%= render AppCardComponent.new(feature: true) do |card| %> + <% card.with_heading(level: 2) do %> + <%= pluralize(@pagy.count, t(".table.record")) %> + <% end %> + + <%= govuk_table(html_attributes: { class: "nhsuk-table nhsuk-table-responsive" }) do |table| %> + <%= table.with_head do |head| + head.with_row do |row| + row.with_cell(text: t(".table.sent_at")) + row.with_cell(text: t(".table.programmes")) + row.with_cell(text: t(".table.status")) + row.with_cell(text: t(".table.records")) + end + end %> + + <%= table.with_body do |body| %> + <% @careplus_reports.each do |report| %> + <%= body.with_row do |row| %> + <%= row.with_cell do %> + <%= t(".table.sent_at") %> + <%= link_to(report.sent_at ? report.sent_at.to_fs(:long) : t(".not_sent"), + careplus_report_path(report)) %> + <% if report.csv_filename.present? %> +
+ + <%= report.csv_filename %> + + <% end %> + <% end %> + + <%= row.with_cell do %> + <%= t(".table.programmes") %> + <%= render AppProgrammeTagsComponent.new(report.programmes) %> + <% end %> + + <%= row.with_cell do %> + <%= t(".table.status") %> + <%= careplus_report_status_tag(report) %> + <% end %> + + <%= row.with_cell do %> + <%= t(".table.records") %> + <%= @careplus_report_records_count_by_report_id[report.id].to_i %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% else %> +

<%= t(".empty") %>

+<% end %> + +<%= render AppPaginationComponent.new(pagy: @pagy) %> diff --git a/app/views/careplus_reports/show.html.erb b/app/views/careplus_reports/show.html.erb new file mode 100644 index 0000000000..1181bbb643 --- /dev/null +++ b/app/views/careplus_reports/show.html.erb @@ -0,0 +1,104 @@ +<% content_for :navigation do %> + <%= render AppBreadcrumbComponent.new(items: [ + { text: t("dashboard.index.title"), href: dashboard_path }, + { text: t("reports.index.title"), href: reports_path }, + { text: t("careplus_reports.index.title"), href: careplus_reports_path }, + ]) %> +<% end %> + +<% title = "CarePlus report (#{@careplus_report.sent_at.to_fs(:long)})" %> +<%= h1 title, page_title: title %> + +<%= render AppCardComponent.new do |card| %> + <% card.with_heading(level: 2) { t(".summary.heading") } %> + + <%= govuk_summary_list do |summary_list| %> + <% if @careplus_report.csv_filename.present? %> + <% summary_list.with_row do |row| %> + <% row.with_key { t(".summary.filename") } %> + <% row.with_value { @careplus_report.csv_filename } %> + <% end %> + <% end %> + + <% summary_list.with_row do |row| %> + <% row.with_key { t(".summary.programme") } %> + <% row.with_value { render AppProgrammeTagsComponent.new(@careplus_report.programmes) } %> + <% end %> + + <% summary_list.with_row do |row| %> + <% row.with_key { t(".summary.status") } %> + <% row.with_value { careplus_report_status_tag(@careplus_report) } %> + <% end %> + + <% summary_list.with_row do |row| %> + <% row.with_key { t(".summary.records") } %> + <% row.with_value do %> + <%= @careplus_report.careplus_report_vaccination_records.size %> + <% end %> + <% end %> + + <% if @careplus_report.csv_data.present? %> + <% summary_list.with_row do |row| %> + <% row.with_key { t(".summary.file") } %> + <% row.with_value { link_to t(".summary.download"), download_careplus_report_path(@careplus_report) } %> + <% end %> + <% end %> + <% end %> +<% end %> + +<% if @vaccination_records.any? %> +

<%= t(".records.heading") %>

+ + <%= render AppDetailsComponent.new(expander: true, sticky: true, open: @pagy.page > 1) do |c| %> + <% c.with_summary do %> + <%= pluralize(@pagy.count, t(".table.record")) %> + <% if @pagy.count > @pagy.limit %> + (showing <%= @pagy.from %> to <%= @pagy.to %>) + <% end %> + <% end %> +
+ <%= govuk_table(html_attributes: { class: "nhsuk-table nhsuk-table-responsive" }) do |table| %> + <%= table.with_head do |head| + head.with_row do |row| + row.with_cell(text: t(".table.name")) + row.with_cell(text: t(".table.postcode")) + row.with_cell(text: t(".table.school")) + row.with_cell(text: t(".table.date_of_birth")) + end + end %> + + <%= table.with_body do |body| %> + <% @vaccination_records.each do |record| %> + <%= body.with_row do |row| %> + <%= row.with_cell do %> + <%= t(".table.name") %> + <%= link_to record.patient.full_name, vaccination_record_path(record) %> +
+ + <%= patient_nhs_number(record.patient) %> + + <% end %> + + <%= row.with_cell do %> + <%= t(".table.postcode") %> + <%= record.patient.address_postcode.presence || "—" %> + <% end %> + + <%= row.with_cell do %> + <%= t(".table.school") %> + <%= record.patient.school&.name || "—" %> + <% end %> + + <%= row.with_cell do %> + <%= t(".table.date_of_birth") %> + <%= record.patient.date_of_birth.to_fs(:long) %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + + <%= render AppPaginationComponent.new(pagy: @pagy) %> +
+ <% end %> +<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index cda81162f7..0fef13e86b 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1228,6 +1228,40 @@ en: mmr_and_mmrv: MMR(V) mmrv: MMRV td_ipv: Td/IPV + careplus_reports: + index: + title: CarePlus reports + description: >- + A record of vaccination reports automatically sent to your CHIS provider's CarePlus system, + every day that new records are available. + not_sent: "—" + empty: No CarePlus reports found. + table: + sent_at: Sent at + programmes: Programmes + status: Status + records: Records + record: CarePlus report + show: + title: CarePlus report + summary: + heading: Report details + filename: Filename + programme: Programmes + status: Status + records: Records + file: File + download: Download CSV + table: + record: vaccination record + name: Name + postcode: Postcode + school: School + date_of_birth: Date of birth + records: + heading: Records + download: + no_file: No file is available for this report. reports: index: title: Reports diff --git a/config/locales/status.en.yml b/config/locales/status.en.yml index bee1d1d732..70a3f4481d 100644 --- a/config/locales/status.en.yml +++ b/config/locales/status.en.yml @@ -150,3 +150,14 @@ en: safe_to_vaccinate_nasal: green safe_to_vaccinate_injection_without_gelatine: green safe_to_vaccinate_injection_without_gelatine_flu: green + careplus_report: + label: + pending: Pending + sending: Sending + sent: Sent + failed: Failed + colour: + pending: blue + sending: blue + sent: green + failed: red diff --git a/config/routes.rb b/config/routes.rb index a1e0114f7c..5b241eae8b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -247,6 +247,13 @@ end end + resources :careplus_reports, + path: "careplus-reports", + controller: "careplus_reports", + only: %i[index show] do + member { get :download } + end + resources :reports, only: :index resources :school_moves, path: "school-moves", only: %i[index show update] From 0f94d221270933629fe29fb73a0bea11a3c256aa Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Tue, 14 Apr 2026 18:30:25 +0100 Subject: [PATCH 02/74] Store whether careplus tab should appear in reporting app in cookie * Refactor existing navigation cookie and new careplus_tab_visible cookie into one Jira-Issue: MAV-5325 --- app/controllers/application_controller.rb | 21 ++++ .../concerns/navigation_concern.rb | 7 -- app/controllers/manifest_controller.rb | 2 +- .../application_controller_spec.rb | 118 ++++++++++++++++++ 4 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 spec/controllers/application_controller_spec.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index ecabf89c99..3360aed323 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -21,6 +21,7 @@ class ApplicationController < ActionController::Base include NavigationConcern + after_action :set_reporting_app_context_cookie after_action :verify_authorized, if: -> { Rails.env.local? } after_action :verify_policy_scoped, if: -> { Rails.env.local? } @@ -104,4 +105,24 @@ def set_privacy_policy_url def set_sentry_user Sentry.set_user(id: current_user&.id) end + + def set_reporting_app_context_cookie + return unless current_user + + cookies[:mavis_reporting_context] = { + navigation_items: reporting_app_navigation_items, + careplus_reports_tab_visible: careplus_reports_tab_visible? + }.to_json + end + + def reporting_app_navigation_items + @navigation_items || [] + end + + def careplus_reports_tab_visible? + return false unless current_user&.cis2_info + return false unless current_team + + current_team.eligible_for_automated_careplus_reports? + end end diff --git a/app/controllers/concerns/navigation_concern.rb b/app/controllers/concerns/navigation_concern.rb index cbae127d7b..bb89268400 100644 --- a/app/controllers/concerns/navigation_concern.rb +++ b/app/controllers/concerns/navigation_concern.rb @@ -6,7 +6,6 @@ module NavigationConcern included do before_action :set_cached_counts before_action :set_navigation_items - after_action :set_navigation_items_cookie end def set_cached_counts @@ -81,10 +80,4 @@ def set_navigation_items @navigation_items << { title: "Tools", path: inspect_dashboard_path } end end - - def set_navigation_items_cookie - return unless current_user - - cookies[:mavis_navigation_items] = @navigation_items.to_json - end end diff --git a/app/controllers/manifest_controller.rb b/app/controllers/manifest_controller.rb index 648d6ba932..86bbc7562a 100644 --- a/app/controllers/manifest_controller.rb +++ b/app/controllers/manifest_controller.rb @@ -5,7 +5,7 @@ class ManifestController < ApplicationController skip_before_action :store_user_location! skip_after_action :verify_authorized skip_after_action :verify_policy_scoped - skip_after_action :set_navigation_items_cookie + skip_after_action :set_reporting_app_context_cookie before_action :set_assets_name diff --git a/spec/controllers/application_controller_spec.rb b/spec/controllers/application_controller_spec.rb new file mode 100644 index 0000000000..2e3c72afb4 --- /dev/null +++ b/spec/controllers/application_controller_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +describe ApplicationController do + controller do + skip_before_action :authenticate_user! + skip_before_action :store_user_location! + skip_before_action :ensure_team_is_selected + skip_before_action :set_user_cis2_info + skip_before_action :set_disable_cache_headers + skip_before_action :set_header_path + skip_before_action :set_assets_name + skip_before_action :set_theme_colour + skip_before_action :set_service_name + skip_before_action :set_service_url + skip_before_action :set_service_guide_url + skip_before_action :set_privacy_policy_url + skip_before_action :set_sentry_user + skip_before_action :authenticate_basic + skip_before_action :set_cached_counts + skip_before_action :set_navigation_items + skip_after_action :verify_authorized + skip_after_action :verify_policy_scoped + + def index + head :ok + end + end + + before { routes.draw { get "index" => "anonymous#index" } } + + describe "after_action :set_reporting_app_context_cookie" do + let(:team) { create(:team) } + let(:user) { create(:user, team:) } + let(:cis2_info) { double(team:) } + let(:navigation_items) { [] } + let(:reporting_app_context_cookie) do + JSON.parse(cookies[:mavis_reporting_context]) + end + + before do + allow(user).to receive(:cis2_info).and_return(cis2_info) + allow(controller).to receive_messages( + current_user: user, + reporting_app_navigation_items: navigation_items + ) + end + + context "when the current team has CarePlus credentials" do + let(:team) { create(:team, :with_careplus_enabled) } + + it "sets the reporting app context cookie" do + get :index + + expect(reporting_app_context_cookie).to eq( + { "navigation_items" => [], "careplus_reports_tab_visible" => true } + ) + end + end + + context "when the current team does not have CarePlus credentials" do + let(:team) { create(:team) } + + it "sets the CarePlus flag to false" do + get :index + + expect( + reporting_app_context_cookie["careplus_reports_tab_visible"] + ).to be(false) + end + end + + context "when the current team has only some CarePlus credentials" do + let(:team) { create(:team, careplus_namespace: "MOCK") } + + it "sets the CarePlus flag to false" do + get :index + + expect( + reporting_app_context_cookie["careplus_reports_tab_visible"] + ).to be(false) + end + end + + context "when the current user does not yet have cis2 info" do + let(:cis2_info) { nil } + + it "sets the reporting app context cookie with the CarePlus flag false" do + get :index + + expect(reporting_app_context_cookie).to eq( + { "navigation_items" => [], "careplus_reports_tab_visible" => false } + ) + end + end + + context "when there is no current user" do + it "does not set the cookie" do + allow(controller).to receive(:current_user).and_return(nil) + + get :index + + expect(cookies[:mavis_reporting_context]).to be_nil + end + end + + context "when navigation items are available" do + let(:navigation_items) { [{ title: "Reports", path: "/reports" }] } + + it "includes navigation items in the reporting app context cookie" do + get :index + + expect(reporting_app_context_cookie["navigation_items"]).to eq( + [{ "title" => "Reports", "path" => "/reports" }] + ) + end + end + end +end From 95b3983c5114c9b8a8714010b28fb6ac8578c128 Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Mon, 20 Apr 2026 14:51:23 +0100 Subject: [PATCH 03/74] Readd existing navigation cookie (to be reverted) * We'll keep this cookie present for now * It will be removed once the reporting component is using the new cookie Jira-Issue: MAV-5325 --- app/controllers/concerns/navigation_concern.rb | 8 ++++++++ app/controllers/manifest_controller.rb | 1 + 2 files changed, 9 insertions(+) diff --git a/app/controllers/concerns/navigation_concern.rb b/app/controllers/concerns/navigation_concern.rb index bb89268400..ffef8fa1a8 100644 --- a/app/controllers/concerns/navigation_concern.rb +++ b/app/controllers/concerns/navigation_concern.rb @@ -6,6 +6,7 @@ module NavigationConcern included do before_action :set_cached_counts before_action :set_navigation_items + after_action :set_navigation_items_cookie end def set_cached_counts @@ -80,4 +81,11 @@ def set_navigation_items @navigation_items << { title: "Tools", path: inspect_dashboard_path } end end + + # TODO: remove this cookie once the reporting app is using the new mavis_reporting_context cookie + def set_navigation_items_cookie + return unless current_user + + cookies[:mavis_navigation_items] = @navigation_items.to_json + end end diff --git a/app/controllers/manifest_controller.rb b/app/controllers/manifest_controller.rb index 86bbc7562a..02dcd76090 100644 --- a/app/controllers/manifest_controller.rb +++ b/app/controllers/manifest_controller.rb @@ -5,6 +5,7 @@ class ManifestController < ApplicationController skip_before_action :store_user_location! skip_after_action :verify_authorized skip_after_action :verify_policy_scoped + skip_after_action :set_navigation_items_cookie skip_after_action :set_reporting_app_context_cookie before_action :set_assets_name From 0458e12c265440eb117ab6b3a75c22593621a37a Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 24 Apr 2026 14:09:21 +0100 Subject: [PATCH 04/74] Rename `notify` throttle configuration To `govuk_notify` to make it clearer what this refers to (particularly if we migrate to NHS Notify in the future). Jira-Issue: MAV-7107 --- app/jobs/concerns/notify_throttling_concern.rb | 2 +- config/initializers/sidekiq.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/jobs/concerns/notify_throttling_concern.rb b/app/jobs/concerns/notify_throttling_concern.rb index 08c2b40172..2184c84929 100644 --- a/app/jobs/concerns/notify_throttling_concern.rb +++ b/app/jobs/concerns/notify_throttling_concern.rb @@ -7,7 +7,7 @@ module NotifyThrottlingConcern include Sidekiq::Throttled::Job included do - sidekiq_throttle_as :notify + sidekiq_throttle_as :govuk_notify queue_as :notifications end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 97e266831d..1f7833ece5 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -82,7 +82,7 @@ def call(_worker, job, _queue) # https://docs.notifications.service.gov.uk/rest-api.html#rate-limits Sidekiq::Throttled::Registry.add( - :notify, + :govuk_notify, threshold: { limit: Settings.govuk_notify.rate_limit_per_second.to_i, period: 1.second From b0547a39f9c726c2d1072a7a2b8a030bc32369fb Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Fri, 24 Apr 2026 14:30:08 +0100 Subject: [PATCH 05/74] Organise job schedule configuration This orders the job according to when they run with commented sections to make it clearer. Jira-Issue: MAV-7107 --- config/sidekiq.yml | 77 ++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 05a19f6b6d..ef9a62db65 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -18,18 +18,48 @@ :scheduler: :schedule: - EnqueueVaccinationsSearchInNHSJob: - cron: "0 6 * * *" - description: Find upcoming sessions and enqueue jobs to find vaccinations for patients in them + # Half-hourly + Metrics::ExportSchoolMovesCountJob: + every: 30m + description: Export school moves count metric + + # Hourly + ReportingAPI::RefreshJob: + cron: "0 * * * *" + description: Refresh the reporting API materialized view data + + # 6-hourly + EnqueueUpdatePatientsFromPDSJob: + cron: "0 0,6,12,18 * * *" + description: Keep patient details up to date with PDS + + # Each afternoon and evening + SendVaccinationConfirmationsJob: + cron: "0 13,16,19 * * *" + description: Send vaccination confirmation emails to parents + + # Nightly + RemoveImportCSVJob: + cron: "0 1 * * *" + description: Remove CSV data from old cohort and immunisation imports GIASImportJob: cron: "30 1 * * *" description: Download and import GIAS data InvalidateSelfConsentsJob: cron: "0 2 * * *" description: Invalidate all self-consents and associated triage for the previous day - Metrics::ExportSchoolMovesCountJob: - every: 30m - description: Export school moves count metric + TrimActiveRecordSessionsJob: + cron: "0 2 * * *" + description: Remove ActiveRecord sessions older than 30 days + EnqueueAutomatedCareplusReportsJob: + cron: "30 2 * * *" + description: Enqueue automated CarePlus reports for teams with CarePlus configured + PatientStatusUpdaterJob: + cron: "0 3 * * *" + description: Updates the status of all patients + EnqueueProcessUnmatchedConsentFormsJob: + cron: "0 4 * * *" + description: Re-process unmatched consent forms to attempt automatic matching EnqueuePatientsAgedOutOfSchoolsJob: cron: "0 5 * * *" description: Moves patients who have aged out of their school to unknown school @@ -41,36 +71,17 @@ description: >- Record already vaccinated for patients who refused consent in the previous academic year for that reason - ReportingAPI::RefreshJob: - cron: "0 * * * *" - description: Refresh the reporting API materialized view data - RemoveImportCSVJob: - cron: "0 1 * * *" - description: Remove CSV data from old cohort and immunisation imports + EnqueueVaccinationsSearchInNHSJob: + cron: "0 6 * * *" + description: Find upcoming sessions and enqueue jobs to find vaccinations for patients in them + + # Daily + EnqueueSchoolSessionRemindersJob: + cron: "0 9 * * *" + description: Send school session reminder emails to parents EnqueueSchoolConsentRequestsJob: cron: "0 16 * * *" description: Send school consent request emails to parents for each session EnqueueSchoolConsentRemindersJob: cron: "15 16 * * *" description: Send school consent reminder emails to parents for each session - EnqueueSchoolSessionRemindersJob: - cron: "0 9 * * *" - description: Send school session reminder emails to parents - PatientStatusUpdaterJob: - cron: "0 3 * * *" - description: Updates the status of all patients - TrimActiveRecordSessionsJob: - cron: "0 2 * * *" - description: Remove ActiveRecord sessions older than 30 days - EnqueueUpdatePatientsFromPDSJob: - cron: "0 0,6,12,18 * * *" - description: Keep patient details up to date with PDS - EnqueueProcessUnmatchedConsentFormsJob: - cron: "0 4 * * *" - description: Re-process unmatched consent forms to attempt automatic matching - SendVaccinationConfirmationsJob: - cron: "0 13,16,19 * * *" - description: Send vaccination confirmation emails to parents - EnqueueAutomatedCareplusReportsJob: - cron: "30 2 * * *" - description: Enqueue automated CarePlus reports for teams with CarePlus configured From 9494c9394360052cf0c9b2cac85ae7baaf65bbb8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Sun, 26 Apr 2026 16:13:55 +0100 Subject: [PATCH 06/74] Remove `import_concurrency_per_server` feature flag The flag doesn't do anything as the code to support it is commented out. If we need to bring the flag back we can revert this commit. Jira-Issue: MAV-7107 --- app/jobs/commit_patient_changesets_job.rb | 11 ----------- config/feature_flags.yml | 3 --- 2 files changed, 14 deletions(-) diff --git a/app/jobs/commit_patient_changesets_job.rb b/app/jobs/commit_patient_changesets_job.rb index e746f4dce1..4c80fdac5f 100644 --- a/app/jobs/commit_patient_changesets_job.rb +++ b/app/jobs/commit_patient_changesets_job.rb @@ -5,17 +5,6 @@ class CommitPatientChangesetsJob include Sidekiq::Throttled::Job include PatientImportConcern - # sidekiq_throttle concurrency: { - # limit: 1, - # key_suffix: ->(_) do - # if Flipper.enabled?(:import_concurrency_per_server) - # Socket.gethostname - # else - # "" - # end - # end - # } - queue_as :imports def perform(patient_changeset_ids) diff --git a/config/feature_flags.yml b/config/feature_flags.yml index 6616779802..29fd7ad16e 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -36,9 +36,6 @@ import_bulk_remove_parents: Allow user to bulk remove parent relationships from import_choose_academic_year: >- Add an option to choose the academic year when importing patients during the preparation period. -import_concurrency_per_server: >- - Controls whether to limit the concurrency of the import jobs to be global or to be per-server. - ops_tools: Enable the operational support tools; timeline and graph. pds: Main switch for PDS integration. Turning this off will disable all PDS-related features. From 4d9d600713cdd423fa9d469e7488257da57c2db2 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Mon, 30 Mar 2026 14:48:23 +0100 Subject: [PATCH 07/74] Add Imports::JoinRecords service Jira-Issue: MAV-7110 --- app/lib/imports/join_records.rb | 94 +++++++++++++++ app/models/concerns/csv_importable.rb | 28 ----- app/models/concerns/patient_import_concern.rb | 18 +-- app/models/immunisation_import.rb | 11 +- spec/lib/imports/join_records_spec.rb | 114 ++++++++++++++++++ 5 files changed, 214 insertions(+), 51 deletions(-) create mode 100644 app/lib/imports/join_records.rb create mode 100644 spec/lib/imports/join_records_spec.rb diff --git a/app/lib/imports/join_records.rb b/app/lib/imports/join_records.rb new file mode 100644 index 0000000000..b9c9b345dd --- /dev/null +++ b/app/lib/imports/join_records.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +# Creates join records between an import and the records created for it as part +# of the import process, e.g. +Patient+, +Parent+, +VaccinationRecord+, etc, +# records. +# +# This module is intended to be used as a small service object: +# +# Imports::JoinRecords.call(import, records) +# +# It determines: +# - +import_type+ from +import.class.name+ (e.g. +ClassImport+, +CohortImport+) +# - +records_type+ either from +records_type:+ (if provided) or by inferring +# from +records+ (all records must be the same class). +# +# The join table model is resolved in one of two ways: +# 1) If a join model constant exists (e.g. +"ClassImportsPatient"+), it is used. +# 2) Otherwise, an anonymous +ApplicationRecord+ subclass is created with: +# - +table_name+ set to +"_"+, +# for example +"class_imports_patients"+ (depending on inflections) +# - a stable +model_name+ suitable for ActiveModel integration +# +# When calling {#call}, rows are bulk-inserted using +#import+ on the join model, +# ignoring duplicates: +# - columns: +"_id"+ and +"_id"+ +# - values: each record id paired with the import's +id+ +# +# Duplicate join rows are ignored via +on_duplicate_key_ignore: true+. +# +# @attr_reader import [ApplicationRecord] the import instance being joined +# @attr_reader import_type [String] class name of the import (e.g. "ClassImport") +# @attr_reader records [Array] records to be joined to the import +# @attr_reader records_type [String] class name of the records (e.g. "Patient") +module Imports + class JoinRecords + attr_reader :import, :import_type, :records, :records_type + + def initialize(import, records, records_type: nil) + @import = import + @records = records + + # e.g. ClassImport, CohortImport, ImmunisationImport + @import_type = import.class.name + + # We don't do this sooner because it may be useful for debugging to + # populate what we can in instances where there are no records to actually + # operate on. + return if records.blank? + + # e.g. Patient, Parent, ParentRelationship, VaccinationRecord + @records_type = + records_type&.classify || records.map(&:class).uniq.sole.name + end + + def self.call(...) = new(...).call + + def call + return [] if records.blank? + + join_table_class.import( + ["#{records_type.underscore}_id", "#{import_type.underscore}_id"], + records.map(&:id).product([import.id]).uniq, + on_duplicate_key_ignore: true + ) + end + + private + + # Resolve (or generate) the ActiveRecord model representing the join table. + # + # @return [Class] join model class + def join_table_class + join_table_name = "#{import_type.pluralize}#{records_type}" + outer_import_type = @import_type + outer_records_type = @records_type + + join_table_name.safe_constantize || + Class.new(ApplicationRecord) do + @import_type = outer_import_type + @records_type = outer_records_type + + self.table_name = "#{@import_type.tableize}_#{@records_type.tableize}" + + def self.model_name + ActiveModel::Name.new( + self, + nil, + [@import_type, @records_type].sort.join + ) + end + end + end + end +end diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 7fb7a6a63d..2bf6520c4a 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -222,32 +222,4 @@ def ensure_processed_with_count_statistics raise "Count statistics must be set for a processed import." end end - - def join_table_class(import_type, record_type) - Class.new(ApplicationRecord) do - @import_type = import_type.to_s.pluralize - @record_type = record_type.to_s - - self.table_name = [@import_type, @record_type.pluralize].sort.join("_") - - def self.model_name - ActiveModel::Name.new( - self, - nil, - [@import_type.camelize, @record_type.singularize.camelize].sort.join - ) - end - end - end - - def link_records_by_type(type, records) - import_type = self.class.name.underscore - type = type.to_s - - join_table_class(import_type, type).import( - ["#{type.singularize}_id", "#{import_type}_id"], - records.map(&:id).product([id]).uniq, - on_duplicate_key_ignore: true - ) - end end diff --git a/app/models/concerns/patient_import_concern.rb b/app/models/concerns/patient_import_concern.rb index 1b39cad8ab..6886078101 100644 --- a/app/models/concerns/patient_import_concern.rb +++ b/app/models/concerns/patient_import_concern.rb @@ -17,7 +17,7 @@ def import_patients_and_parents(changesets, import) patients.select(&:nhs_number_previously_changed?) Patient.import(patients.to_a, on_duplicate_key_update: :all) - link_records_to_import(import, Patient, patients) + Imports::JoinRecords.call(import, patients) SearchVaccinationRecordsInNHSJob.perform_bulk( patients_with_nhs_number_changes.pluck(:id).zip @@ -27,7 +27,7 @@ def import_patients_and_parents(changesets, import) PatientChangeset.import(changesets, on_duplicate_key_update: :all) Parent.import(parents.to_a, on_duplicate_key_update: :all) - link_records_to_import(import, Parent, parents) + Imports::JoinRecords.call(import, parents) ParentRelationship.import( relationships.to_a, @@ -36,7 +36,7 @@ def import_patients_and_parents(changesets, import) columns: %i[type other_name] } ) - link_records_to_import(import, ParentRelationship, relationships) + Imports::JoinRecords.call(import, relationships) end def deduplicate_patients!(patients, relationships) @@ -103,18 +103,6 @@ def import_pds_search_results(changesets, import) PDSSearchResult.import(pds_search_records, on_duplicate_key_ignore: true) end - def link_records_to_import(import_source, record_class, records) - source_type = import_source.class.name - record_type = record_class.name - - join_table_class = "#{source_type.pluralize}#{record_type}".constantize - join_table_class.import( - ["#{source_type.underscore}_id", "#{record_type.underscore}_id"], - records.map { [import_source.id, it.id] }, - on_duplicate_key_ignore: true - ) - end - def increment_column_counts!(import, counts, changesets) changesets.each do |changeset| count_column_to_increment = diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 51601a3e86..579abd337a 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -173,14 +173,9 @@ def bulk_import(rows: 100) ArchiveReason.import(archive_reasons, on_duplicate_key_ignore: :all) - [ - [:vaccination_records, vaccination_records], - [:patients, @patients_batch], - [:patient_locations, patient_locations.select { it.id.present? }] - ].each do |association, collection| - link_records_by_type(association, collection) - collection.clear - end + Imports::JoinRecords.call(self, vaccination_records) + Imports::JoinRecords.call(self, patients) + Imports::JoinRecords.call(self, patient_locations.select { it.id.present? }) @patients_batch.clear @vaccination_records_batch.clear diff --git a/spec/lib/imports/join_records_spec.rb b/spec/lib/imports/join_records_spec.rb new file mode 100644 index 0000000000..80554cee89 --- /dev/null +++ b/spec/lib/imports/join_records_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +describe Imports::JoinRecords do + let(:import) { create(:class_import) } + let(:patients) { create_list(:patient, 2) } + + describe ".call" do + subject(:call) { described_class.call(import, patients) } + + it "inserts join records between the import and records" do + expect { call }.to change(ClassImportsPatient, :count).by(2) + end + end + + describe "#call" do + subject(:call) { described_class.new(import, records).call } + + context "when records is empty" do + let(:records) { [] } + + it { should eq([]) } + + it "does not insert any records" do + expect { call }.not_to change(ClassImportsPatient, :count) + end + end + + context "with a named join model constant" do + let(:records) { patients } + + it "uses ClassImportsPatient and inserts join records" do + expect { call }.to change(ClassImportsPatient, :count).by(2) + end + + it "associates the records with the import" do + call + expect(ClassImportsPatient.distinct.pluck(:class_import_id)).to eq( + [import.id] + ) + expect(ClassImportsPatient.pluck(:patient_id)).to match_array( + patients.map(&:id) + ) + end + + context "when called twice with the same records" do + before { described_class.new(import, records).call } + + it "ignores duplicate rows" do + expect { call }.not_to change(ClassImportsPatient, :count) + end + end + end + + context "without a named join model constant" do + let(:import) { create(:immunisation_import) } + let(:records) { patients } + let(:join_table) do + Class.new(ApplicationRecord) do + self.table_name = "immunisation_imports_patients" + end + end + + it "inserts join records using the inferred table name" do + expect { call }.to change(join_table, :count).by(2) + end + end + + context "with records_type provided as a string" do + let(:records) { patients } + + it "classifies and uses the provided records_type" do + expect { + described_class.new(import, records, records_type: "patient").call + }.to change(ClassImportsPatient, :count).by(2) + end + end + end + + describe "#import_type" do + subject(:join_records) { described_class.new(import, patients) } + + it "equals the import class name" do + expect(join_records.import_type).to eq("ClassImport") + end + end + + describe "#records_type" do + context "when inferred from records" do + subject(:join_records) { described_class.new(import, patients) } + + it "equals the records class name" do + expect(join_records.records_type).to eq("Patient") + end + end + + context "when provided via keyword argument" do + subject(:join_records) do + described_class.new(import, patients, records_type: "patient") + end + + it "is classified from the given string" do + expect(join_records.records_type).to eq("Patient") + end + end + + context "when records is empty" do + subject(:join_records) { described_class.new(import, []) } + + it "is nil" do + expect(join_records.records_type).to be_nil + end + end + end +end From 8c00ef77ca68fb3064bf462655f01eec5aff28f1 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Mon, 30 Mar 2026 11:36:17 +0100 Subject: [PATCH 08/74] Rename processed? to processed_at? Jira-issue: MAV-6746 --- app/components/app_import_summary_component.html.erb | 2 +- app/controllers/class_imports_controller.rb | 2 +- app/controllers/cohort_imports_controller.rb | 2 +- app/jobs/process_patient_changeset_job.rb | 2 +- app/models/concerns/csv_importable.rb | 6 +----- app/views/imports/show.html.erb | 2 +- 6 files changed, 6 insertions(+), 10 deletions(-) diff --git a/app/components/app_import_summary_component.html.erb b/app/components/app_import_summary_component.html.erb index ca09254b15..93ebbfbe62 100644 --- a/app/components/app_import_summary_component.html.erb +++ b/app/components/app_import_summary_component.html.erb @@ -68,7 +68,7 @@ import.parent_relationships.present? %> <% if import.removing_parent_relationships? %>

Parent-child relationships are currently being removed from this import

- <% elsif import.processed? && import.parent_relationships.any? %> + <% elsif import.processed_at? && import.parent_relationships.any? %>

<%= link_to "Remove all parent-child relationships from import", imports_bulk_remove_parents_path(import.class.name.underscore, import.id) %>

diff --git a/app/controllers/class_imports_controller.rb b/app/controllers/class_imports_controller.rb index ff10481a78..7b18fb8e28 100644 --- a/app/controllers/class_imports_controller.rb +++ b/app/controllers/class_imports_controller.rb @@ -49,7 +49,7 @@ def show redirect_to re_review_class_import_path(@class_import) and return end - if @class_import.processed? || @class_import.partially_processed? + if @class_import.processed_at? || @class_import.partially_processed? @pagy, @patients = pagy(@class_import.patients.includes(:school)) @duplicates = diff --git a/app/controllers/cohort_imports_controller.rb b/app/controllers/cohort_imports_controller.rb index 0b46990875..c468c26db5 100644 --- a/app/controllers/cohort_imports_controller.rb +++ b/app/controllers/cohort_imports_controller.rb @@ -48,7 +48,7 @@ def show redirect_to re_review_cohort_import_path(@cohort_import) and return end - if @cohort_import.processed? || @cohort_import.partially_processed? + if @cohort_import.processed_at? || @cohort_import.partially_processed? @pagy, @patients = pagy(@cohort_import.patients.includes(:school)) @duplicates = diff --git a/app/jobs/process_patient_changeset_job.rb b/app/jobs/process_patient_changeset_job.rb index 0176af93b1..bcacc6f22c 100644 --- a/app/jobs/process_patient_changeset_job.rb +++ b/app/jobs/process_patient_changeset_job.rb @@ -5,7 +5,7 @@ class ProcessPatientChangesetJob < ApplicationJob def perform(patient_changeset_id) patient_changeset = PatientChangeset.find(patient_changeset_id) - return if patient_changeset.processed? + return if patient_changeset.processed_at? unique_nhs_number = get_unique_nhs_number(patient_changeset) if unique_nhs_number diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 7fb7a6a63d..bd03e6f9a8 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -141,10 +141,6 @@ def parse_rows! end end - def processed? - processed_at != nil - end - def remove! return if csv_removed? update!(csv_data: nil, csv_removed_at: Time.zone.now) @@ -218,7 +214,7 @@ def count_columns end def ensure_processed_with_count_statistics - if processed? && count_columns.any? { |column| send(column).nil? } + if processed_at? && count_columns.any? { |column| send(column).nil? } raise "Count statistics must be set for a processed import." end end diff --git a/app/views/imports/show.html.erb b/app/views/imports/show.html.erb index b1a52a34c5..dbc88dd823 100644 --- a/app/views/imports/show.html.erb +++ b/app/views/imports/show.html.erb @@ -77,7 +77,7 @@ ) %> <% end %> -<% if import.processed? || import.partially_processed? || import.removing_parent_relationships? %> +<% if import.processed_at? || import.partially_processed? || import.removing_parent_relationships? %> <% if @cancelled.present? %>

From 98b4630a034c8687fa3e4b9c914863397c3c720c Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 17 Apr 2026 14:39:12 +0100 Subject: [PATCH 09/74] It's cohort-imports not cohort_imports Somehow we added the path to view cohort imports as '/cohort_imports'. Probably my fault, as usual. Jira-Issue: MAV-6734 --- config/routes.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config/routes.rb b/config/routes.rb index 4e72c89584..bf8d4ee2b1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -136,8 +136,9 @@ end end + get "/cohort_imports/:id", to: redirect("/cohort-imports/%{id}") resources :cohort_imports, - path: "cohort_imports", + path: "cohort-imports", except: %i[index destroy] do member do get :re_review, to: "cohort_imports#re_review" From fcb426e0385a8ebaa04c0b0c9a0b9b80186ddf5e Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 27 Apr 2026 14:00:41 +0100 Subject: [PATCH 10/74] Fix `NoMethodError` in `SearchImmsAPIJob` If a patient is only associated with national reporting teams, then a search is not completed for this patient. This causes `NHS::ImmunisationsAPI.search_immunisations` to return `nil`. This was not being properly handled by the job, and was causing a noisy error in production, and the job to fail. The job failure has no impact on the data; these searches were intended to return no results anyway. --- .../search_vaccination_records_in_nhs_job.rb | 2 + ...rch_vaccination_records_in_nhs_job_spec.rb | 45 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/app/jobs/search_vaccination_records_in_nhs_job.rb b/app/jobs/search_vaccination_records_in_nhs_job.rb index dbc56a9a9f..84ca37acf6 100644 --- a/app/jobs/search_vaccination_records_in_nhs_job.rb +++ b/app/jobs/search_vaccination_records_in_nhs_job.rb @@ -97,6 +97,8 @@ def incoming_vaccination_records fhir_bundle = NHS::ImmunisationsAPI.search_immunisations(patient, programmes:) + return [] if fhir_bundle.nil? + extract_fhir_vaccination_records(fhir_bundle) .then { convert_to_vaccination_records(it) } .then { reject_service_sourced_records(it) } diff --git a/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb b/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb index 933dbb10dd..05156a11eb 100644 --- a/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb +++ b/spec/jobs/search_vaccination_records_in_nhs_job_spec.rb @@ -18,6 +18,51 @@ Flipper.disable(:imms_api_search_job) end + describe "#incoming_vaccination_records" do + subject(:incoming_vaccination_records) do + injected_described_class = + described_class.new.tap do |job| + job.instance_variable_set(:@patient, patient) + job.instance_variable_set(:@programmes, Programme.all_as_variants) + end + injected_described_class.send(:incoming_vaccination_records) + end + + let(:bundle) do + FHIR.from_contents( + file_fixture("fhir/search_responses/2_results.json").read + ) + end + + before do + allow(NHS::ImmunisationsAPI).to receive(:search_immunisations).with( + patient, + programmes: Programme.all_as_variants + ).and_return(bundle) + end + + it "returns only VaccinationRecords" do + expect(incoming_vaccination_records).to all(be_a(VaccinationRecord)) + expect(incoming_vaccination_records.size).to eq 2 + end + + context "when the patient has no NHS number" do + let(:nhs_number) { nil } + + it "returns an empty array" do + expect(incoming_vaccination_records).to be_empty + end + end + + context "when the bundle is nil" do + let(:bundle) { nil } + + it "returns an empty array" do + expect(incoming_vaccination_records).to be_empty + end + end + end + describe "#extract_vaccination_records" do let(:bundle) do FHIR.from_contents( From 44c2c52912d78631ef5c6eeb0dcb661ca6c6ad84 Mon Sep 17 00:00:00 2001 From: Jake Benilov Date: Mon, 27 Apr 2026 22:17:31 +0100 Subject: [PATCH 11/74] Wait for fanned-out search jobs in testing API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 55bb07811 wired `?wait=true` to `EnqueueVaccinationsSearchInNHSJob.perform_now`, but that job uses `perform_bulk` to fan out per-patient `SearchVaccinationRecordsInNHSJob`s onto the `immunisations_api_search` Sidekiq queue. Those run async on workers, so the controller returned 200 before any patient was actually searched — defeating the point of the wait flag for end-to-end tests. Poll the queue after `perform_now` returns and only respond once it has drained (or after a 5-minute timeout). --- .../vaccinations_search_in_nhs_controller.rb | 19 +++++++++++++++++++ .../vaccinations_search_in_nhs_spec.rb | 3 +++ 2 files changed, 22 insertions(+) diff --git a/app/controllers/api/testing/vaccinations_search_in_nhs_controller.rb b/app/controllers/api/testing/vaccinations_search_in_nhs_controller.rb index 647264fc8c..7a18a3b691 100644 --- a/app/controllers/api/testing/vaccinations_search_in_nhs_controller.rb +++ b/app/controllers/api/testing/vaccinations_search_in_nhs_controller.rb @@ -1,13 +1,32 @@ # frozen_string_literal: true class API::Testing::VaccinationsSearchInNHSController < API::Testing::BaseController + POLL_INTERVAL = 0.25 + POLL_TIMEOUT = 300 + def create if params[:wait].present? EnqueueVaccinationsSearchInNHSJob.perform_now + wait_for_search_jobs_to_complete render status: :ok else EnqueueVaccinationsSearchInNHSJob.perform_later render status: :accepted end end + + private + + # EnqueueVaccinationsSearchInNHSJob fans out to per-patient + # SearchVaccinationRecordsInNHSJob jobs via perform_bulk. Poll + # until Sidekiq workers have drained the queue so callers see + # updated patient statuses when the response arrives. + def wait_for_search_jobs_to_complete + queue = Sidekiq::Queue.new("immunisations_api_search") + deadline = Time.current + POLL_TIMEOUT + + # rubocop:disable Style/ZeroLengthPredicate -- Sidekiq::Queue has no #empty? + sleep POLL_INTERVAL until queue.size.zero? || Time.current > deadline + # rubocop:enable Style/ZeroLengthPredicate + end end diff --git a/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb b/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb index bde1d7a3e4..ae49ce3b63 100644 --- a/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb +++ b/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb @@ -17,6 +17,9 @@ context "with wait=true" do before do allow(EnqueueVaccinationsSearchInNHSJob).to receive(:perform_now) + allow(Sidekiq::Queue).to receive(:new).with( + "immunisations_api_search" + ).and_return(instance_double(Sidekiq::Queue, size: 0)) end it "runs the job synchronously and responds with ok" do From 2a684b1f505a6c492026f4a922bb3af0dfe83c31 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:51:47 +0000 Subject: [PATCH 12/74] Bump ruby/setup-ruby from 1.303.0 to 1.305.0 Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.303.0 to 1.305.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/60ecfba8750476ff216b59eee3b88218bb5111cc...0cb964fd540e0a24c900370abf38a33466142735) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-version: 1.305.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/build-and-push-database-image.yml | 2 +- .github/workflows/deploy-documentation.yml | 2 +- .github/workflows/end-to-end-tests-local.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-push-database-image.yml b/.github/workflows/build-and-push-database-image.yml index d0d3fd32cf..54d0887d47 100644 --- a/.github/workflows/build-and-push-database-image.yml +++ b/.github/workflows/build-and-push-database-image.yml @@ -57,7 +57,7 @@ jobs: echo "Waiting for postgres..." sleep 2 done' - - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + - uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true - name: Set up database for testing diff --git a/.github/workflows/deploy-documentation.yml b/.github/workflows/deploy-documentation.yml index 9c227712ad..b1c8d2a9af 100644 --- a/.github/workflows/deploy-documentation.yml +++ b/.github/workflows/deploy-documentation.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + - uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true diff --git a/.github/workflows/end-to-end-tests-local.yml b/.github/workflows/end-to-end-tests-local.yml index 5593ba4e33..cae85b2312 100644 --- a/.github/workflows/end-to-end-tests-local.yml +++ b/.github/workflows/end-to-end-tests-local.yml @@ -31,7 +31,7 @@ jobs: node-version-file: .tool-versions cache: yarn - name: Setup Ruby on base branch - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true - name: Install JS dependencies on base branch @@ -48,7 +48,7 @@ jobs: node-version-file: .tool-versions cache: yarn - name: Setup Ruby on head branch - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true - name: Install JS dependencies on head branch diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 149a4985c7..73365dfba6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + - uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29709bd1d3..5f704ea547 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: with: node-version-file: .tool-versions cache: yarn - - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + - uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true - name: Precompile assets @@ -68,7 +68,7 @@ jobs: with: node-version-file: .tool-versions cache: yarn - - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 + - uses: ruby/setup-ruby@0cb964fd540e0a24c900370abf38a33466142735 # v1.305.0 with: bundler-cache: true - name: Run seeds From ab5b350f57c4662c97decbf4da58f3803549bbb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:52:43 +0000 Subject: [PATCH 13/74] Bump actions/setup-node from 6.3.0 to 6.4.0 Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: 6.4.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/build-and-push-database-image.yml | 2 +- .github/workflows/end-to-end-tests-local.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-and-push-database-image.yml b/.github/workflows/build-and-push-database-image.yml index d0d3fd32cf..c15f48afb6 100644 --- a/.github/workflows/build-and-push-database-image.yml +++ b/.github/workflows/build-and-push-database-image.yml @@ -32,7 +32,7 @@ jobs: with: ref: ${{ inputs.github_ref || github.ref_name == 'next' && 'next' || github.ref_name }} repository: nhsdigital/manage-vaccinations-in-schools - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .tool-versions cache: yarn diff --git a/.github/workflows/end-to-end-tests-local.yml b/.github/workflows/end-to-end-tests-local.yml index 5593ba4e33..34d138c3e8 100644 --- a/.github/workflows/end-to-end-tests-local.yml +++ b/.github/workflows/end-to-end-tests-local.yml @@ -26,7 +26,7 @@ jobs: with: ref: ${{ github.base_ref }} - name: Setup Node.js on base branch - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .tool-versions cache: yarn @@ -43,7 +43,7 @@ jobs: with: ref: ${{ github.head_ref }} - name: Setup Node.js on head branch - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .tool-versions cache: yarn diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 149a4985c7..7304f99bbb 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: cache: yarn node-version-file: .tool-versions diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29709bd1d3..e3dc5f37c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,7 +26,7 @@ jobs: DATABASE_PASSWORD: postgres steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .tool-versions cache: yarn @@ -64,7 +64,7 @@ jobs: postgis://postgres:postgres@localhost:5432/manage_vaccinations_development steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .tool-versions cache: yarn @@ -79,7 +79,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version-file: .tool-versions cache: yarn From a315b4725b1498073364f9ce22b3cf5d35254f47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:53:30 +0000 Subject: [PATCH 14/74] Bump aws-sdk-ec2 from 1.611.0 to 1.612.0 Bumps [aws-sdk-ec2](https://github.com/aws/aws-sdk-ruby) from 1.611.0 to 1.612.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.612.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 c55d1db4e9..5b422221a1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,7 +178,7 @@ GEM protocol-websocket (~> 0.17) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1240.0) + aws-partitions (1.1241.0) aws-sdk-accessanalyzer (1.88.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) @@ -193,7 +193,7 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-ec2 (1.611.0) + aws-sdk-ec2 (1.612.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) aws-sdk-ecr (1.125.0) From 1e2d5ceb80ae69b055e857c8ea0459f86662361f Mon Sep 17 00:00:00 2001 From: Mike Thompson Date: Tue, 21 Apr 2026 11:16:32 +0100 Subject: [PATCH 15/74] Add CIS2 info values to UI on login failure to help with debugging Jira-Issue: MAV-7070 --- app/views/users/errors/_cis2_details.html.erb | 28 +++++++++++++++++++ .../errors/organisation_not_found.html.erb | 2 ++ .../users/errors/role_not_found.html.erb | 4 ++- .../users/errors/workgroup_not_found.html.erb | 2 ++ ...entication_with_wrong_organisation_spec.rb | 8 ++++++ ...is2_authentication_with_wrong_role_spec.rb | 8 ++++++ ...uthentication_with_wrong_workgroup_spec.rb | 8 ++++++ 7 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 app/views/users/errors/_cis2_details.html.erb diff --git a/app/views/users/errors/_cis2_details.html.erb b/app/views/users/errors/_cis2_details.html.erb new file mode 100644 index 0000000000..e983566128 --- /dev/null +++ b/app/views/users/errors/_cis2_details.html.erb @@ -0,0 +1,28 @@ +

Your Care Identity details

+ +

+ You can share these details with your registration authority and the Mavis team to help them identify the problem: +

+ +<%= govuk_summary_list do |summary_list| + summary_list.with_row do |row| + row.with_key { "ODS code" } + row.with_value { @cis2_info.organisation_code.presence || "Not provided" } + end + + summary_list.with_row do |row| + row.with_key { "Workgroup".pluralize(@cis2_info.workgroups&.length || 0) } + row.with_value do + if @cis2_info.workgroups.present? + @cis2_info.workgroups.join(", ") + else + "Not provided" + end + end + end + + summary_list.with_row do |row| + row.with_key { "Role" } + row.with_value { @cis2_info.role_code.presence || "Not provided" } + end + end %> diff --git a/app/views/users/errors/organisation_not_found.html.erb b/app/views/users/errors/organisation_not_found.html.erb index 4ee2ac126d..9ca663620b 100644 --- a/app/views/users/errors/organisation_not_found.html.erb +++ b/app/views/users/errors/organisation_not_found.html.erb @@ -14,3 +14,5 @@ <% if @cis2_info.has_other_roles %> <%= govuk_button_to "Change role", user_cis2_omniauth_authorize_path, params: { change_role: true } %> <% end %> + +<%= render "cis2_details" %> diff --git a/app/views/users/errors/role_not_found.html.erb b/app/views/users/errors/role_not_found.html.erb index 4ebf768be3..de8f7ac0a0 100644 --- a/app/views/users/errors/role_not_found.html.erb +++ b/app/views/users/errors/role_not_found.html.erb @@ -4,7 +4,7 @@ <%= govuk_button_to "Change role", user_cis2_omniauth_authorize_path, params: { change_role: true } %> <% end %> -

Contact your registration authority

+

Contact your registration authority

If you think you should have permission to use this service, contact your @@ -24,3 +24,5 @@ "Nurse Access Role (R8001)", "Medical Secretary Access Role (R8006)", ], type: "bullet") %> + +<%= render "cis2_details" %> diff --git a/app/views/users/errors/workgroup_not_found.html.erb b/app/views/users/errors/workgroup_not_found.html.erb index 53c091da34..6fd614518e 100644 --- a/app/views/users/errors/workgroup_not_found.html.erb +++ b/app/views/users/errors/workgroup_not_found.html.erb @@ -9,3 +9,5 @@ <% if @cis2_info.has_other_roles %> <%= govuk_button_to "Change role", user_cis2_omniauth_authorize_path, params: { change_role: true } %> <% end %> + +<%= render "cis2_details" %> diff --git a/spec/features/user_cis2_authentication_with_wrong_organisation_spec.rb b/spec/features/user_cis2_authentication_with_wrong_organisation_spec.rb index bb9a9f7e66..d71430c67b 100644 --- a/spec/features/user_cis2_authentication_with_wrong_organisation_spec.rb +++ b/spec/features/user_cis2_authentication_with_wrong_organisation_spec.rb @@ -8,6 +8,7 @@ when_i_click_the_cis2_login_button then_i_see_the_organisation_not_found_error + and_i_see_my_care_identity_details given_my_team_has_been_setup_in_mavis when_i_click_the_change_role_button @@ -79,6 +80,13 @@ def then_i_see_the_organisation_not_found_error ) end + def and_i_see_my_care_identity_details + expect(page).to have_heading("Your Care Identity details") + expect(page).to have_content("ODS codeA9A5A") + expect(page).to have_content("Workgroupa9a5a") + expect(page).to have_content("RoleS8000:G8000:R8001") + end + def when_i_click_the_change_role_button click_button "Change role" end diff --git a/spec/features/user_cis2_authentication_with_wrong_role_spec.rb b/spec/features/user_cis2_authentication_with_wrong_role_spec.rb index c79c55920a..d9c9b77962 100644 --- a/spec/features/user_cis2_authentication_with_wrong_role_spec.rb +++ b/spec/features/user_cis2_authentication_with_wrong_role_spec.rb @@ -7,6 +7,7 @@ then_i_am_on_the_start_page when_i_click_the_cis2_login_button then_i_see_the_wrong_role_error + and_i_see_my_care_identity_details when_i_click_the_change_role_button_and_select_the_right_role then_i_see_the_session_page @@ -40,6 +41,13 @@ def then_i_see_the_wrong_role_error ) end + def and_i_see_my_care_identity_details + expect(page).to have_heading("Your Care Identity details") + expect(page).to have_content("ODS codeA9A5A") + expect(page).to have_content("WorkgroupsNot provided") + expect(page).to have_content("RoleS8000:G8000:R8004") + end + def when_i_click_the_change_role_button_and_select_the_right_role # With don't actually get to select the right role directly in our test # setup so we change the cis2 response to simulate it. diff --git a/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb b/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb index 3b87302d76..53a1d69f9d 100644 --- a/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb +++ b/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb @@ -7,6 +7,7 @@ then_i_am_on_the_start_page when_i_click_the_cis2_login_button then_i_see_the_wrong_workgroup_error + and_i_see_my_care_identity_details when_i_click_the_change_role_button_and_select_the_right_role then_i_see_the_session_page @@ -40,6 +41,13 @@ def then_i_see_the_wrong_workgroup_error ) end + def and_i_see_my_care_identity_details + expect(page).to have_heading("Your Care Identity details") + expect(page).to have_content("ODS codeA9A5A") + expect(page).to have_content("Workgroupwrong-workgroup") + expect(page).to have_content("RoleS8000:G8000:R8001") + end + def when_i_click_the_change_role_button_and_select_the_right_role # With don't actually get to select the right role directly in our test # setup so we change the cis2 response to simulate it. From 3830d3a6a908bc7ab4dd042d7a4c9fa5ea98f8ac Mon Sep 17 00:00:00 2001 From: Mike Thompson Date: Tue, 28 Apr 2026 06:45:56 +0100 Subject: [PATCH 16/74] Add activity codes to CIS2 debug output --- app/views/users/errors/_cis2_details.html.erb | 13 +++++++++++++ ...cis2_authentication_with_wrong_workgroup_spec.rb | 8 +++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/views/users/errors/_cis2_details.html.erb b/app/views/users/errors/_cis2_details.html.erb index e983566128..51330a9701 100644 --- a/app/views/users/errors/_cis2_details.html.erb +++ b/app/views/users/errors/_cis2_details.html.erb @@ -25,4 +25,17 @@ row.with_key { "Role" } row.with_value { @cis2_info.role_code.presence || "Not provided" } end + + summary_list.with_row do |row| + row.with_key do + "Activity code".pluralize(@cis2_info.activity_codes&.length || 0) + end + row.with_value do + if @cis2_info.activity_codes.present? + @cis2_info.activity_codes.join(", ") + else + "Not provided" + end + end + end end %> diff --git a/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb b/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb index 53a1d69f9d..ff2ad66884 100644 --- a/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb +++ b/spec/features/user_cis2_authentication_with_wrong_workgroup_spec.rb @@ -16,7 +16,10 @@ def given_i_am_setup_in_mavis_and_cis2_but_with_the_wrong_role @team = create(:team, ods_code: "A9A5A") - mock_cis2_auth(workgroups: ["wrong-workgroup"]) + mock_cis2_auth( + workgroups: ["wrong-workgroup"], + activity_codes: [CIS2Info::INDEPENDENT_PRESCRIBING_ACTIVITY_CODE] + ) end def when_i_click_the_cis2_login_button @@ -46,6 +49,9 @@ def and_i_see_my_care_identity_details expect(page).to have_content("ODS codeA9A5A") expect(page).to have_content("Workgroupwrong-workgroup") expect(page).to have_content("RoleS8000:G8000:R8001") + expect(page).to have_content( + "Activity code#{CIS2Info::INDEPENDENT_PRESCRIBING_ACTIVITY_CODE}" + ) end def when_i_click_the_change_role_button_and_select_the_right_role From 0aeb2bb4037dcfbe5645a28b8759c3af2829b3cc Mon Sep 17 00:00:00 2001 From: John Henderson Date: Tue, 28 Apr 2026 09:08:30 +0100 Subject: [PATCH 17/74] Add indexes to improve join table performance Poor performance has been noted when deleting records, particularly in the bulk-remove parents flow and when bulk-deleting vaccination records. A likely cause is that some join tables are only indexed in one direction, which can make deletion queries slower. Jira-Issue: MAV-6735 --- .../class_imports_parent_relationship.rb | 1 + .../cohort_imports_parent_relationship.rb | 1 + ...d_reverse_indexes_to_import_join_tables.rb | 26 +++++++++++++++++++ db/schema.rb | 5 +++- 4 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260428080729_add_reverse_indexes_to_import_join_tables.rb diff --git a/app/models/class_imports_parent_relationship.rb b/app/models/class_imports_parent_relationship.rb index e6df374621..7edd105841 100644 --- a/app/models/class_imports_parent_relationship.rb +++ b/app/models/class_imports_parent_relationship.rb @@ -10,6 +10,7 @@ # Indexes # # idx_on_class_import_id_parent_relationship_id_8225058195 (class_import_id,parent_relationship_id) UNIQUE +# idx_on_parent_relationship_id_class_import_id_d7c05d6c2c (parent_relationship_id,class_import_id) UNIQUE # # Foreign Keys # diff --git a/app/models/cohort_imports_parent_relationship.rb b/app/models/cohort_imports_parent_relationship.rb index 3b36a37f21..bc1104626a 100644 --- a/app/models/cohort_imports_parent_relationship.rb +++ b/app/models/cohort_imports_parent_relationship.rb @@ -10,6 +10,7 @@ # Indexes # # idx_on_cohort_import_id_parent_relationship_id_c65e20d1f8 (cohort_import_id,parent_relationship_id) UNIQUE +# idx_on_parent_relationship_id_cohort_import_id_40fb9846d6 (parent_relationship_id,cohort_import_id) UNIQUE # # Foreign Keys # diff --git a/db/migrate/20260428080729_add_reverse_indexes_to_import_join_tables.rb b/db/migrate/20260428080729_add_reverse_indexes_to_import_join_tables.rb new file mode 100644 index 0000000000..3deaa9447c --- /dev/null +++ b/db/migrate/20260428080729_add_reverse_indexes_to_import_join_tables.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class AddReverseIndexesToImportJoinTables < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + INDEXES = { + class_imports_parent_relationships: %i[ + parent_relationship_id + class_import_id + ], + cohort_imports_parent_relationships: %i[ + parent_relationship_id + cohort_import_id + ], + immunisation_imports_vaccination_records: %i[ + vaccination_record_id + immunisation_import_id + ] + }.freeze + + def change + INDEXES.each do |table_name, columns| + add_index table_name, columns, unique: true, algorithm: :concurrently + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 13e90601f7..bfedd4ecdc 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_04_20_122440) do +ActiveRecord::Schema[8.1].define(version: 2026_04_28_080729) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -162,6 +162,7 @@ t.bigint "class_import_id", null: false t.bigint "parent_relationship_id", null: false t.index ["class_import_id", "parent_relationship_id"], name: "idx_on_class_import_id_parent_relationship_id_8225058195", unique: true + t.index ["parent_relationship_id", "class_import_id"], name: "idx_on_parent_relationship_id_class_import_id_d7c05d6c2c", unique: true end create_table "class_imports_parents", id: false, force: :cascade do |t| @@ -219,6 +220,7 @@ t.bigint "cohort_import_id", null: false t.bigint "parent_relationship_id", null: false t.index ["cohort_import_id", "parent_relationship_id"], name: "idx_on_cohort_import_id_parent_relationship_id_c65e20d1f8", unique: true + t.index ["parent_relationship_id", "cohort_import_id"], name: "idx_on_parent_relationship_id_cohort_import_id_40fb9846d6", unique: true end create_table "cohort_imports_parents", id: false, force: :cascade do |t| @@ -455,6 +457,7 @@ t.bigint "immunisation_import_id", null: false t.bigint "vaccination_record_id", null: false t.index ["immunisation_import_id", "vaccination_record_id"], name: "idx_on_immunisation_import_id_vaccination_record_id_588e859772", unique: true + t.index ["vaccination_record_id", "immunisation_import_id"], name: "idx_on_vaccination_record_id_immunisation_import_id_813c516ad7", unique: true end create_table "important_notices", force: :cascade do |t| From 1574fafb12034a82984ca3223dee9aebc4fdd1f3 Mon Sep 17 00:00:00 2001 From: Chris Lowis Date: Mon, 27 Apr 2026 11:38:36 +0100 Subject: [PATCH 18/74] Update ImmunisationImport status after post_commit! In 50b02d6469c we moved the calls to `PatientTeamUpdater.call` and `PatientStatusUpdater.call` inside the `post_commit!` method. However we didn't move this call to `update_columns`. Before this commit an import could appear "processed" to the user but when they look at the patients and teams in that import they may not have the correct details. This was previously also an issue with the other steps inside `post_commit!`. They also happened *after* the call to `update_columns`. I think it makes more sense to update the status of the import when *all* the associated tasks are completed. The exact execution order of these steps wasn't tested before and I haven't attempted to add a test here either, relying instead on the increased clarity of everything that needs to happen after the transaction is commited running as the last call in `process!`. --- app/models/immunisation_import.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 51601a3e86..64944c8860 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -84,11 +84,11 @@ def process! bulk_import(rows: :all) postprocess_rows! - - update_columns(processed_at: Time.zone.now, status: :processed, **counts) end post_commit! + + update_columns(processed_at: Time.zone.now, status: :processed, **counts) end private From 5150094e61468df68987024e613c3391d8bf2f41 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 23 Apr 2026 16:45:36 +0100 Subject: [PATCH 19/74] Add `LocationPositionUpdater` This adds a class which handles updating the position of a location by fetching the information from the Ordnance Survey Places API. Jira-Issue: MAV-6379 --- app/lib/location_position_updater.rb | 48 ++++++++ spec/factories/locations.rb | 8 ++ spec/lib/location_position_updater_spec.rb | 132 +++++++++++++++++++++ 3 files changed, 188 insertions(+) create mode 100644 app/lib/location_position_updater.rb create mode 100644 spec/lib/location_position_updater_spec.rb diff --git a/app/lib/location_position_updater.rb b/app/lib/location_position_updater.rb new file mode 100644 index 0000000000..2fffd10367 --- /dev/null +++ b/app/lib/location_position_updater.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +## +# This class fetches the latitude and longitude of a location's address +# using the +OrdnanceSurvey::PlacesAPI+ and stores it in the position column. +class LocationPositionUpdater + class MissingAddress < StandardError + end + + class NoResults < StandardError + end + + def initialize(location) + @location = location + end + + attr_reader :location + + def call + raise MissingAddress unless location.has_address? + + location.update!(position:) + end + + def self.call(...) = new(...).call + + private_class_method :new + + private + + def full_address = location.address_parts.join(", ") + + def position + response = OrdnanceSurvey::PlacesAPI.find(full_address) + + results = response[:results] + raise NoResults if results.blank? + + first_result = results.first + + latitude = first_result.dig(:dpa, :lat) + longitude = first_result.dig(:dpa, :lng) + + raise NoResults if latitude.blank? || longitude.blank? + + "POINT(#{longitude} #{latitude})" + end +end diff --git a/spec/factories/locations.rb b/spec/factories/locations.rb index db14b8cd0c..7723a17e11 100644 --- a/spec/factories/locations.rb +++ b/spec/factories/locations.rb @@ -58,6 +58,14 @@ end end + trait :without_address do + address_line_1 { nil } + address_line_2 { nil } + address_town { nil } + address_postcode { nil } + position { nil } + end + factory :community_clinic do type { :community_clinic } name { "#{Faker::University.name} Clinic" } diff --git a/spec/lib/location_position_updater_spec.rb b/spec/lib/location_position_updater_spec.rb new file mode 100644 index 0000000000..043f24f9e2 --- /dev/null +++ b/spec/lib/location_position_updater_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +describe LocationPositionUpdater do + describe "#call" do + subject(:call) { described_class.call(location) } + + let(:location) do + create( + :community_clinic, + address_line_1: "1 High Street", + address_town: "London", + address_postcode: "SW1A 1AA", + position: nil + ) + end + + context "when location has no address" do + let(:location) { create(:community_clinic, :without_address) } + + it "raises an error" do + expect { call }.to raise_error(LocationPositionUpdater::MissingAddress) + end + + it "does not call the API" do + expect(OrdnanceSurvey::PlacesAPI).not_to receive(:find) + expect { call }.to raise_error(LocationPositionUpdater::MissingAddress) + end + + it "does not update the position" do + expect { call }.to raise_error( + LocationPositionUpdater::MissingAddress + ).and not_change(location, :position) + end + end + + context "when API returns coordinates" do + let(:response) do + { + header: { + total_results: 1 + }, + results: [{ dpa: { lat: 51.5074, lng: -0.1278 } }] + } + end + + before do + allow(OrdnanceSurvey::PlacesAPI).to receive(:find).and_return(response) + end + + it "calls the API with the address" do + expect(OrdnanceSurvey::PlacesAPI).to receive(:find).with( + "1 High Street, London, SW1A 1AA" + ) + call + end + + it "updates the location's position" do + expect { call }.to change(location, :position).from(nil).to( + an_instance_of(RGeo::Geographic::SphericalPointImpl) + ) + expect(location.position.x).to eq(-0.1278) + expect(location.position.y).to eq(51.5074) + end + end + + context "when API returns no results" do + let(:response) { { header: { total_results: 0 }, results: [] } } + + before do + allow(OrdnanceSurvey::PlacesAPI).to receive(:find).and_return(response) + end + + it "raises an error" do + expect { call }.to raise_error(LocationPositionUpdater::NoResults) + end + end + + context "when API returns a result without coordinates" do + let(:response) do + { header: { total_results: 1 }, results: [{ dpa: {} }] } + end + + before do + allow(OrdnanceSurvey::PlacesAPI).to receive(:find).and_return(response) + end + + it "raises an error" do + expect { call }.to raise_error(LocationPositionUpdater::NoResults) + end + end + + context "when location has partial address" do + let(:location) do + create( + :community_clinic, + address_line_1: "1 High Street", + address_town: nil, + address_postcode: "SW1A 1AA", + position: nil + ) + end + + let(:response) do + { + header: { + total_results: 1 + }, + results: [{ dpa: { lat: 51.5074, lng: -0.1278 } }] + } + end + + before do + allow(OrdnanceSurvey::PlacesAPI).to receive(:find).and_return(response) + end + + it "calls the API with partial address" do + expect(OrdnanceSurvey::PlacesAPI).to receive(:find).with( + "1 High Street, SW1A 1AA" + ) + call + end + + it "updates the location position" do + expect { call }.to change(location, :position).from(nil).to( + an_instance_of(RGeo::Geographic::SphericalPointImpl) + ) + expect(location.position.x).to eq(-0.1278) + expect(location.position.y).to eq(51.5074) + end + end + end +end From b583eff191862241e4c4fc693f807b3e1fcc2de2 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 23 Apr 2026 16:53:30 +0100 Subject: [PATCH 20/74] Add `LocationPositionUpdaterJob` This adds a job which calls the `LocationPositionUpdater` service class for a location, allowing it's position to be updated in the background. Jira-Issue: MAV-6379 --- app/jobs/location_position_updater_job.rb | 14 ++++ .../location_position_updater_job_spec.rb | 65 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 app/jobs/location_position_updater_job.rb create mode 100644 spec/jobs/location_position_updater_job_spec.rb diff --git a/app/jobs/location_position_updater_job.rb b/app/jobs/location_position_updater_job.rb new file mode 100644 index 0000000000..1fce2ade3b --- /dev/null +++ b/app/jobs/location_position_updater_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class LocationPositionUpdaterJob + include Sidekiq::Job + + sidekiq_options queue: :third_party_data_imports, lock: :until_executing + + def perform(location_id) + location = Location.find(location_id) + LocationPositionUpdater.call(location) + rescue LocationPositionUpdater::NoResults => e + Sentry.capture_exception(e, level: "warning") + end +end diff --git a/spec/jobs/location_position_updater_job_spec.rb b/spec/jobs/location_position_updater_job_spec.rb new file mode 100644 index 0000000000..6867ab835d --- /dev/null +++ b/spec/jobs/location_position_updater_job_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +describe LocationPositionUpdaterJob do + describe "#perform" do + subject(:perform) { described_class.new.perform(location.id) } + + let(:location) { create(:community_clinic, position: nil) } + + context "when the location has an address" do + let(:response) do + { + header: { + total_results: 1 + }, + results: [{ dpa: { lat: 51.5074, lng: -0.1278 } }] + } + end + + before do + allow(OrdnanceSurvey::PlacesAPI).to receive(:find).and_return(response) + end + + it "calls LocationPositionUpdater with the location" do + expect(LocationPositionUpdater).to receive(:call).with(location) + perform + end + + it "updates the location position" do + expect { perform }.to change { location.reload.position }.from(nil).to( + an_instance_of(RGeo::Geographic::SphericalPointImpl) + ) + end + end + + context "when there are no results" do + let(:error) { LocationPositionUpdater::NoResults.new } + + before do + allow(LocationPositionUpdater).to receive(:call).and_raise(error) + end + + it "captures the exception in Sentry at warning level" do + expect(Sentry).to receive(:capture_exception).with( + error, + level: "warning" + ) + perform + end + + it "does not raise the error" do + expect { perform }.not_to raise_error + end + end + + context "when the location has no address" do + let(:location) { create(:community_clinic, :without_address) } + + it "re-raises the error" do + expect { perform }.to raise_error( + LocationPositionUpdater::MissingAddress + ) + end + end + end +end From 83edff4c55a22f2fefa0b40bc00aaaddccf5bbab Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 23 Apr 2026 22:26:19 +0100 Subject: [PATCH 21/74] Add `EnqueueLocationPositionUpdaterJob` This adds a job which enqueues a `LocationPositionUpdaterJob` for each location with an address but without a position. It's set up to run at midnight, in a low priority queue. Jira-Issue: MAV-6379 --- .../enqueue_location_position_updater_job.rb | 10 ++++++ app/models/concerns/address_concern.rb | 10 ++++++ config/sidekiq.yml | 4 +++ ...ueue_location_position_updater_job_spec.rb | 35 +++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 app/jobs/enqueue_location_position_updater_job.rb create mode 100644 spec/jobs/enqueue_location_position_updater_job_spec.rb diff --git a/app/jobs/enqueue_location_position_updater_job.rb b/app/jobs/enqueue_location_position_updater_job.rb new file mode 100644 index 0000000000..3be3bce77f --- /dev/null +++ b/app/jobs/enqueue_location_position_updater_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class EnqueueLocationPositionUpdaterJob < ApplicationJob + queue_as :third_party_data_imports + + def perform + ids = Location.where(position: nil).has_address.ids + LocationPositionUpdaterJob.perform_bulk(ids.zip) + end +end diff --git a/app/models/concerns/address_concern.rb b/app/models/concerns/address_concern.rb index af76b34b03..b72f57a1fd 100644 --- a/app/models/concerns/address_concern.rb +++ b/app/models/concerns/address_concern.rb @@ -18,6 +18,16 @@ module AddressConcern has_one :local_authority_from_postcode, through: :local_authority_postcode, source: :local_authority + + scope :has_address, + -> do + where("address_line_1 IS NOT NULL AND address_line_1 <> ''") + .or(where("address_line_2 IS NOT NULL AND address_line_2 <> ''")) + .or(where("address_town IS NOT NULL AND address_town <> ''")) + .or( + where("address_postcode IS NOT NULL AND address_postcode <> ''") + ) + end end def address_parts diff --git a/config/sidekiq.yml b/config/sidekiq.yml index ef9a62db65..8b0bd72abb 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -39,6 +39,10 @@ description: Send vaccination confirmation emails to parents # Nightly + EnqueueLocationPositionUpdaterJob: + cron: "0 0 * * *" + description: + Enqueue jobs to fetch latitude and longitude for locations with addresses but no position RemoveImportCSVJob: cron: "0 1 * * *" description: Remove CSV data from old cohort and immunisation imports diff --git a/spec/jobs/enqueue_location_position_updater_job_spec.rb b/spec/jobs/enqueue_location_position_updater_job_spec.rb new file mode 100644 index 0000000000..670d3f1dc1 --- /dev/null +++ b/spec/jobs/enqueue_location_position_updater_job_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +describe EnqueueLocationPositionUpdaterJob do + describe "#perform" do + subject(:perform_now) { described_class.perform_now } + + let!(:location_with_address_no_position) do + create(:community_clinic, position: nil) + end + + let!(:location_with_position) { create(:community_clinic) } + + let!(:location_without_address) do + create(:community_clinic, :without_address) + end + + it "enqueues jobs for locations with address but no position" do + expect { perform_now }.to enqueue_sidekiq_job( + LocationPositionUpdaterJob + ).with(location_with_address_no_position.id) + end + + it "does not enqueue jobs for locations with position" do + expect { perform_now }.not_to enqueue_sidekiq_job( + LocationPositionUpdaterJob + ).with(location_with_position.id) + end + + it "does not enqueue jobs for locations without address" do + expect { perform_now }.not_to enqueue_sidekiq_job( + LocationPositionUpdaterJob + ).with(location_without_address.id) + end + end +end From ea4c4336b73d450c9496b94e09b4077bc3ad90b9 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 23 Apr 2026 23:03:19 +0100 Subject: [PATCH 22/74] Fetch location positions on creation When creating new locations, either via onboarding a new team or by explicitly creating a new clinic, we should fetch the position of the location so that it's up to date. I've intentionally not done this after the GIAS import as we have a job that runs overnight which collects any locations that have an address and no location so these will eventually be up to date. Jira-Issue: MAV-6379 --- app/lib/mavis_cli/clinics/create.rb | 19 +++++++++++-------- app/models/onboarding.rb | 8 +++++++- spec/features/cli_clinics_create_spec.rb | 7 +++++++ spec/models/onboarding_spec.rb | 6 ++++++ 4 files changed, 31 insertions(+), 9 deletions(-) diff --git a/app/lib/mavis_cli/clinics/create.rb b/app/lib/mavis_cli/clinics/create.rb index bda4d50807..6542a5d682 100644 --- a/app/lib/mavis_cli/clinics/create.rb +++ b/app/lib/mavis_cli/clinics/create.rb @@ -26,14 +26,17 @@ def call( ) MavisCLI.load_rails - Location.create!( - type: :community_clinic, - name:, - address_line_1:, - address_town:, - address_postcode:, - ods_code: - ) + location = + Location.create!( + type: :community_clinic, + name:, + address_line_1:, + address_town:, + address_postcode:, + ods_code: + ) + + LocationPositionUpdaterJob.perform_async(location.id) end end end diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb index 89404cc292..497aad52d9 100644 --- a/app/models/onboarding.rb +++ b/app/models/onboarding.rb @@ -249,6 +249,10 @@ def save!(include_previous_academic_year: false) end PatientTeamUpdater.call(team_scope: Team.where(id: team.id)) + + location_ids = + schools.map(&:id) + clinics.keys.filter(&:has_address?).map(&:id) + LocationPositionUpdaterJob.perform_bulk(location_ids.zip) end end @@ -311,7 +315,7 @@ def location @location ||= Location.gias_school.find_by_urn_and_site(urn) end - delegate :status, to: :location, allow_nil: true + delegate :id, :status, to: :location, allow_nil: true delegate :team, to: :subteam def save! @@ -347,6 +351,8 @@ class NewSchoolSite validates :name, presence: true validates :site, presence: true + delegate :id, to: :location + def original_location @original_location ||= Location.gias_school.find_by_urn_and_site(urn) end diff --git a/spec/features/cli_clinics_create_spec.rb b/spec/features/cli_clinics_create_spec.rb index 14fd934351..f22f952a45 100644 --- a/spec/features/cli_clinics_create_spec.rb +++ b/spec/features/cli_clinics_create_spec.rb @@ -7,6 +7,7 @@ it "runs successfully" do when_i_run_the_command then_the_clinic_is_created + and_a_location_position_updater_job_is_enqueued end end @@ -29,4 +30,10 @@ def then_the_clinic_is_created expect(clinic.address_town).to eq("Town") expect(clinic.address_postcode).to eq("SW1A 1AA") end + + def and_a_location_position_updater_job_is_enqueued + expect(LocationPositionUpdaterJob).to have_enqueued_sidekiq_job( + Location.last.id + ) + end end diff --git a/spec/models/onboarding_spec.rb b/spec/models/onboarding_spec.rb index a7ffaf8807..d5fccc53fd 100644 --- a/spec/models/onboarding_spec.rb +++ b/spec/models/onboarding_spec.rb @@ -71,10 +71,16 @@ clinic1 = subteam1.community_clinics.find_by!(ods_code: nil) expect(clinic1.name).to eq("10 Downing Street") expect(clinic1.address_postcode).to eq("SW1A 1AA") + expect(LocationPositionUpdaterJob).to have_enqueued_sidekiq_job( + clinic1.id + ) clinic2 = subteam2.community_clinics.find_by!(ods_code: "SW1A11") expect(clinic2.name).to eq("11 Downing Street") expect(clinic2.address_postcode).to eq("SW1A 1AA") + expect(LocationPositionUpdaterJob).to have_enqueued_sidekiq_job( + clinic2.id + ) end end From 87977d3a6c1c2b4b237a1cab733ce8ad47a743a3 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 28 Apr 2026 11:37:53 +0100 Subject: [PATCH 23/74] Refactor `EnqueueAutomatedCareplusReportsJob` This refactors the job to be an `ApplicationJob` like most of our other jobs and queues the subsequent jobs in bulk to improve performance. --- app/jobs/enqueue_automated_careplus_reports_job.rb | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/app/jobs/enqueue_automated_careplus_reports_job.rb b/app/jobs/enqueue_automated_careplus_reports_job.rb index 5380729b3c..c7924640c2 100644 --- a/app/jobs/enqueue_automated_careplus_reports_job.rb +++ b/app/jobs/enqueue_automated_careplus_reports_job.rb @@ -1,13 +1,10 @@ # frozen_string_literal: true -class EnqueueAutomatedCareplusReportsJob - include Sidekiq::Job - - sidekiq_options queue: :careplus +class EnqueueAutomatedCareplusReportsJob < ApplicationJob + queue_as :careplus def perform - Team.eligible_for_automated_careplus_reports.ids.each do |team_id| - SendAutomatedCareplusReportsJob.perform_async(team_id) - end + ids = Team.eligible_for_automated_careplus_reports.ids + SendAutomatedCareplusReportsJob.perform_bulk(ids.zip) end end From 0d0727f06dfdc9d560c75e9d030d4a09a2961ff8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 28 Apr 2026 12:32:11 +0100 Subject: [PATCH 24/74] Only report `LocationPositionUpdater::NoResults` in production In our non-production environments we often have locations that are generated from fake data which means they're almost certainly going to fail. These aren't actionable errors so there's no need to send them to Sentry. Jira-Issue: MAV-6707 Jira-Issue: MAV-6379 --- app/jobs/location_position_updater_job.rb | 9 ++++- config/settings.yml | 3 ++ config/settings/development.yml | 3 ++ config/settings/end_to_end.yml | 3 ++ config/settings/staging.yml | 3 ++ config/settings/test.yml | 3 ++ .../location_position_updater_job_spec.rb | 35 ++++++++++++++----- 7 files changed, 50 insertions(+), 9 deletions(-) diff --git a/app/jobs/location_position_updater_job.rb b/app/jobs/location_position_updater_job.rb index 1fce2ade3b..8c1eaae802 100644 --- a/app/jobs/location_position_updater_job.rb +++ b/app/jobs/location_position_updater_job.rb @@ -9,6 +9,13 @@ def perform(location_id) location = Location.find(location_id) LocationPositionUpdater.call(location) rescue LocationPositionUpdater::NoResults => e - Sentry.capture_exception(e, level: "warning") + if Settings.location_position_updater_job.capture_exception + Sentry.capture_exception(e, level: "warning") + else + Rails.logger.warn( + "Could not fetch position for: #{location.name} (#{location.id})" + ) + Rails.logger.warn(e.backtrace.join("\n")) + end end end diff --git a/config/settings.yml b/config/settings.yml index f90dbca8c5..b48433ebf9 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -64,6 +64,9 @@ ordnance_survey: api_key: <%= Rails.application.credentials.ordnance_survey&.api_key %> secret_key: <%= Rails.application.credentials.ordnance_survey&.secret_key %> +location_position_updater_job: + capture_exception: true + # Set a value to override the default values set in config/initializers/devise.rb devise: timeout_in_seconds: diff --git a/config/settings/development.yml b/config/settings/development.yml index 6dae3bdd51..2bf8626951 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -29,3 +29,6 @@ reporting_api: careplus: base_url: http://localhost:8080 + +location_position_updater_job: + capture_exception: false diff --git a/config/settings/end_to_end.yml b/config/settings/end_to_end.yml index a5176c2925..60cd5b1e28 100644 --- a/config/settings/end_to_end.yml +++ b/config/settings/end_to_end.yml @@ -26,3 +26,6 @@ splunk: reporting_api: client_app: root_url: http://localhost:4101 + +location_position_updater_job: + capture_exception: false diff --git a/config/settings/staging.yml b/config/settings/staging.yml index 94b6fe0dbf..b30544a424 100644 --- a/config/settings/staging.yml +++ b/config/settings/staging.yml @@ -20,3 +20,6 @@ pds: careplus: base_url: <%= ENV.fetch("MOCK_CAREPLUS_URL", nil) %> + +location_position_updater_job: + capture_exception: false diff --git a/config/settings/test.yml b/config/settings/test.yml index 12d13fa569..85f8f5ff9e 100644 --- a/config/settings/test.yml +++ b/config/settings/test.yml @@ -27,3 +27,6 @@ reporting_api: careplus: base_url: http://localhost:8080 + +location_position_updater_job: + capture_exception: false diff --git a/spec/jobs/location_position_updater_job_spec.rb b/spec/jobs/location_position_updater_job_spec.rb index 6867ab835d..7492fb21a8 100644 --- a/spec/jobs/location_position_updater_job_spec.rb +++ b/spec/jobs/location_position_updater_job_spec.rb @@ -39,16 +39,35 @@ allow(LocationPositionUpdater).to receive(:call).and_raise(error) end - it "captures the exception in Sentry at warning level" do - expect(Sentry).to receive(:capture_exception).with( - error, - level: "warning" - ) - perform + context "when not capturing the exception" do + it "doesn't capture the exception in Sentry" do + expect(Sentry).not_to receive(:capture_exception) + perform + end + + it "does not raise the error" do + expect { perform }.not_to raise_error + end end - it "does not raise the error" do - expect { perform }.not_to raise_error + context "when capturing the exception" do + before do + Settings.location_position_updater_job.capture_exception = true + end + + after { Settings.reload! } + + it "captures the exception in Sentry at warning level" do + expect(Sentry).to receive(:capture_exception).with( + error, + level: "warning" + ) + perform + end + + it "does not raise the error" do + expect { perform }.not_to raise_error + end end end From a6d3278d95a2bfc1b532671110b6f2adde6e08e6 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 28 Apr 2026 13:32:18 +0100 Subject: [PATCH 25/74] Fix issue matching locations to positions This fixes an issue where locations don't match as we're using the wrong projection to fetch latitude and longitude values. Jira-Issue: MAV-6707 Jira-Issue: MAV-6379 --- app/lib/location_position_updater.rb | 4 +-- app/lib/ordnance_survey/places_api.rb | 4 +-- spec/lib/location_position_updater_spec.rb | 6 +++-- spec/lib/ordnance_survey/places_api_spec.rb | 28 ++++++++++++++++++--- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/app/lib/location_position_updater.rb b/app/lib/location_position_updater.rb index 2fffd10367..936ff12c7b 100644 --- a/app/lib/location_position_updater.rb +++ b/app/lib/location_position_updater.rb @@ -28,10 +28,10 @@ def self.call(...) = new(...).call private - def full_address = location.address_parts.join(", ") + def query = [location.name, location.address_parts].join(", ") def position - response = OrdnanceSurvey::PlacesAPI.find(full_address) + response = OrdnanceSurvey::PlacesAPI.find(query) results = response[:results] raise NoResults if results.blank? diff --git a/app/lib/ordnance_survey/places_api.rb b/app/lib/ordnance_survey/places_api.rb index ea79f2b8e0..53406dc767 100644 --- a/app/lib/ordnance_survey/places_api.rb +++ b/app/lib/ordnance_survey/places_api.rb @@ -9,8 +9,8 @@ def initialize @base_url = "https://api.os.uk" end - def find(query) - params = { query:, format: "json" } + def find(query, max_results: 1, output_srs: "EPSG:4326") + params = { query:, maxresults: max_results, output_srs:, format: "json" } response = connection.get("/search/places/v1/find", params) response.body.deep_transform_keys(&:downcase).deep_symbolize_keys end diff --git a/spec/lib/location_position_updater_spec.rb b/spec/lib/location_position_updater_spec.rb index 043f24f9e2..0c8c3bbe02 100644 --- a/spec/lib/location_position_updater_spec.rb +++ b/spec/lib/location_position_updater_spec.rb @@ -7,6 +7,7 @@ let(:location) do create( :community_clinic, + name: "Westminster Clinic", address_line_1: "1 High Street", address_town: "London", address_postcode: "SW1A 1AA", @@ -49,7 +50,7 @@ it "calls the API with the address" do expect(OrdnanceSurvey::PlacesAPI).to receive(:find).with( - "1 High Street, London, SW1A 1AA" + "Westminster Clinic, 1 High Street, London, SW1A 1AA" ) call end @@ -93,6 +94,7 @@ let(:location) do create( :community_clinic, + name: "Westminster Clinic", address_line_1: "1 High Street", address_town: nil, address_postcode: "SW1A 1AA", @@ -115,7 +117,7 @@ it "calls the API with partial address" do expect(OrdnanceSurvey::PlacesAPI).to receive(:find).with( - "1 High Street, SW1A 1AA" + "Westminster Clinic, 1 High Street, SW1A 1AA" ) call end diff --git a/spec/lib/ordnance_survey/places_api_spec.rb b/spec/lib/ordnance_survey/places_api_spec.rb index 3f38823c75..57371cfbdf 100644 --- a/spec/lib/ordnance_survey/places_api_spec.rb +++ b/spec/lib/ordnance_survey/places_api_spec.rb @@ -72,7 +72,12 @@ before do stub_request(:get, "#{api_url}/search/places/v1/find").with( - query: hash_including(query:, format: "json"), + query: { + query:, + format: "json", + maxresults: 1, + output_srs: "EPSG:4326" + }, headers: { "Key" => api_key } @@ -103,7 +108,12 @@ context "when the request is invalid" do before do stub_request(:get, "#{api_url}/search/places/v1/find").with( - query: hash_including(query:, format: "json"), + query: { + query:, + format: "json", + maxresults: 1, + output_srs: "EPSG:4326" + }, headers: { "Key" => api_key } @@ -123,7 +133,12 @@ context "when rate limit is exceeded" do before do stub_request(:get, "#{api_url}/search/places/v1/find").with( - query: hash_including(query:, format: "json"), + query: { + query:, + format: "json", + maxresults: 1, + output_srs: "EPSG:4326" + }, headers: { "Key" => api_key } @@ -138,7 +153,12 @@ context "when there is an unexpected error" do before do stub_request(:get, "#{api_url}/search/places/v1/find").with( - query: hash_including(query:, format: "json"), + query: { + query:, + format: "json", + maxresults: 1, + output_srs: "EPSG:4326" + }, headers: { "Key" => api_key } From 58083d6cbb0dfacd7584a79614fc888673f0989a Mon Sep 17 00:00:00 2001 From: James Mead Date: Tue, 28 Apr 2026 16:13:17 +0100 Subject: [PATCH 26/74] Remove unused method in PatientStatusUpdater This method implementation and an invocation from `PatientStatusUpdater#update_programme_statuses!` was originally added in this commit [1] in #6468. Both the implementation and invocation were removed in this commit [2] in #6611. Then the implementation was then re-added without the corresponding invocation in this commit [3] within #6677 which was the "Version 8.1.0" pull request. Since the method is never invoked, it's safe to remove it. [1]: https://github.com/NHSDigital/manage-vaccinations-in-schools/commit/25e876a72c6ce58ce340572904fe9d7d16958ed2 [2]: https://github.com/NHSDigital/manage-vaccinations-in-schools/commit/29460d4a15a3230234ae24ac04babb2609ba2c8c [3]: https://github.com/NHSDigital/manage-vaccinations-in-schools/commit/c66b75728a3d2b0d0e0fa3a42f73a245e301c244 --- app/lib/patient_status_updater.rb | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/app/lib/patient_status_updater.rb b/app/lib/patient_status_updater.rb index 6d7d948299..a5880585c3 100644 --- a/app/lib/patient_status_updater.rb +++ b/app/lib/patient_status_updater.rb @@ -160,23 +160,4 @@ def programme_types_per_session_id_and_year_group hash[session_id][year_group] << programme_type end end - - # We preload this association separately because including it in the nested - # `patient_locations` preload (see includes above) caused the updater process - # to be killed, even with very small batches. The likely cause is memory pressure - # from eager loading a deeply nested association graph. - # - # Preloading it here for the distinct `Location` records in each batch keeps - # `StatusGenerator::Programme` query-free without incurring the cost of the - # larger nested preload. - def preload_location_programme_year_groups(batch) - locations = batch.flat_map(&:patient_locations).map(&:location).uniq - - ActiveRecord::Associations::Preloader.new( - records: locations, - associations: { - location_programme_year_groups: :location_year_group - } - ).call - end end From b3744110d6b275b4a8d7df226615da314dedd149 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Thu, 23 Apr 2026 09:08:34 +0100 Subject: [PATCH 27/74] Fix Imms API location encoding issue with `generic_school` There was a refactor to how homeschool and unknown school locations were handled in the data model. This introduced a bug where vaccination records delivered at these locations weren't being encoded properly when converted to FHIR records. This bug is fixed by this change. --- app/lib/fhir_mapper/location.rb | 4 ++-- spec/lib/fhir_mapper/location_spec.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/lib/fhir_mapper/location.rb b/app/lib/fhir_mapper/location.rb index 07b515d3cb..ed6d0aa5af 100644 --- a/app/lib/fhir_mapper/location.rb +++ b/app/lib/fhir_mapper/location.rb @@ -2,7 +2,7 @@ module FHIRMapper class Location - delegate :gias_school?, :clinic?, :type, :urn, :ods_code, to: :@location + delegate :school?, :clinic?, :type, :urn, :ods_code, to: :@location def initialize(location) @location = location @@ -14,7 +14,7 @@ class UnknownValueError < StandardError end def fhir_reference - if gias_school? + if school? value = urn || UNKNOWN_IDENTIFIER system = "https://fhir.hl7.org.uk/Id/urn-school-number" elsif clinic? diff --git a/spec/lib/fhir_mapper/location_spec.rb b/spec/lib/fhir_mapper/location_spec.rb index c64c50df6b..06b66b7d9d 100644 --- a/spec/lib/fhir_mapper/location_spec.rb +++ b/spec/lib/fhir_mapper/location_spec.rb @@ -19,6 +19,18 @@ its(:value) { should eq "654321" } end + context "location is a generic school" do + let(:location) do + create(:generic_school, urn: "888888", name: "Unknown school") + end + + its(:system) do + should eq "https://fhir.hl7.org.uk/Id/urn-school-number" + end + + its(:value) { should eq "888888" } + end + context "location is a community clinic" do let(:location) { create(:community_clinic, ods_code: "918273") } From c1a726c0e56e5e545c1e1f5707479c83d097220c Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 28 Apr 2026 19:38:02 +0100 Subject: [PATCH 28/74] Don't require Action Mailer We're not using Action Mailer at all in this application, instead we send emails by calling the GOV.UK Notify API directly. By removing the requirement on Action Mailer, we make it explicit in the code that we're not using it, and we should expect a very small speed up when starting the server. --- .rubocop.yml | 6 ------ app/mailers/application_mailer.rb | 4 ---- config/application.rb | 2 +- config/environments/development.rb | 9 --------- 4 files changed, 1 insertion(+), 20 deletions(-) delete mode 100644 app/mailers/application_mailer.rb diff --git a/.rubocop.yml b/.rubocop.yml index ed4d8ec09c..5a1ac29ae0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -66,12 +66,6 @@ RSpec/ContextWording: RSpec/ImplicitExpect: EnforcedStyle: should -RSpec/VerifiedDoubles: - Exclude: - - spec/controllers/concerns/consent_form_mailer_concern_spec.rb - - spec/controllers/concerns/triage_mailer_concern_spec.rb - - spec/controllers/concerns/vaccination_mailer_concern_spec.rb - Style/FrozenStringLiteralComment: Enabled: true EnforcedStyle: always_true diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb deleted file mode 100644 index 26148f2d63..0000000000 --- a/app/mailers/application_mailer.rb +++ /dev/null @@ -1,4 +0,0 @@ -# frozen_string_literal: true - -class ApplicationMailer < ActionMailer::Base -end diff --git a/config/application.rb b/config/application.rb index f1962b201a..d032161ea6 100644 --- a/config/application.rb +++ b/config/application.rb @@ -9,7 +9,7 @@ require "active_record/railtie" require "active_storage/engine" require "action_controller/railtie" -require "action_mailer/railtie" +# require "action_mailer/railtie" # require "action_mailbox/engine" require "action_text/engine" require "action_view/railtie" diff --git a/config/environments/development.rb b/config/environments/development.rb index 1e0bcfb02f..de34add624 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -39,15 +39,6 @@ # Disable Active Storage's image processing, we don't use it and it complains on startup. config.active_storage.variant_processor = :disabled - # Don't care if the mailer can't send. - config.action_mailer.raise_delivery_errors = false - - # Make template changes take effect immediately. - config.action_mailer.perform_caching = false - - # Set localhost to be used by links generated in mailer templates. - config.action_mailer.default_url_options = { host: "localhost", port: 3000 } - # Print deprecation notices to the Rails logger. config.active_support.deprecation = :log From 05d1bddad4e7228a328de81ddc84bdd24daf901b Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 08:34:08 +0100 Subject: [PATCH 29/74] Add normalisation on address columns Currently we're got a mix of `NULL` and empty string values for these columns in production, especially on the `locations` table where GIAS imported values will often have blank string address components, but clinics will use `NULL`. Adding a normalisation ensures that we're eventually consistent about the way these columns are stored. In data replication, patients and consent forms are already normalised, through other means so this is just formalising logic that already exists. Addresses on locations are not currently normalised so this introduces a change in behaviour. Ideally, I would have preferred to make the columns `NOT NULL DEFAULT ''` but this doesn't work for columns using Rails encryption. Jira-Issue: MAV-6707 Jira-Issue: MAV-6379 --- app/models/concerns/address_concern.rb | 5 ++++- spec/models/consent_form_spec.rb | 2 +- spec/models/location_spec.rb | 3 ++- spec/models/patient_spec.rb | 4 ++-- .../shared_examples/a_model_with_an_address.rb | 13 +++++++++++++ 5 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 spec/support/shared_examples/a_model_with_an_address.rb diff --git a/app/models/concerns/address_concern.rb b/app/models/concerns/address_concern.rb index b72f57a1fd..bc59c82b16 100644 --- a/app/models/concerns/address_concern.rb +++ b/app/models/concerns/address_concern.rb @@ -4,9 +4,12 @@ module AddressConcern extend ActiveSupport::Concern included do + normalizes :address_line_1, with: ->(value) { value.presence } + normalizes :address_line_2, with: ->(value) { value.presence } + normalizes :address_town, with: ->(value) { value.presence } normalizes :address_postcode, with: ->(value) do - value.nil? ? nil : UKPostcode.parse(value.to_s).to_s + value.present? ? UKPostcode.parse(value).to_s : nil end belongs_to :local_authority_postcode, diff --git a/spec/models/consent_form_spec.rb b/spec/models/consent_form_spec.rb index c85233f8f6..a9b77fe3d8 100644 --- a/spec/models/consent_form_spec.rb +++ b/spec/models/consent_form_spec.rb @@ -399,8 +399,8 @@ it { should normalize(:given_name).from(" Joanna ").to("Joanna") } it { should normalize(:family_name).from(" Smith ").to("Smith") } - it { should normalize(:address_postcode).from(" SW111AA ").to("SW11 1AA") } + it_behaves_like "a model with an address" it_behaves_like "a model with a normalised email address", :parent_email it_behaves_like "a model with a normalised phone number", :parent_phone diff --git a/spec/models/location_spec.rb b/spec/models/location_spec.rb index 6ac193a507..664b35f042 100644 --- a/spec/models/location_spec.rb +++ b/spec/models/location_spec.rb @@ -193,8 +193,9 @@ end end + it_behaves_like "a model with an address" + describe "normalisations" do - it { should normalize(:address_postcode).from(" SW111AA ").to("SW11 1AA") } it { should normalize(:ods_code).from(" r1a ").to("R1A") } it { should normalize(:urn).from(" 123 ").to("123") } end diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 108a27fd2b..075ea562a0 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -668,10 +668,10 @@ end it { should normalize(:nhs_number).from(" 0123456789 ").to("0123456789") } - - it { should normalize(:address_postcode).from(" SW111AA ").to("SW11 1AA") } end + it_behaves_like "a model with an address" + describe "#teams" do subject(:teams) { patient.teams } diff --git a/spec/support/shared_examples/a_model_with_an_address.rb b/spec/support/shared_examples/a_model_with_an_address.rb new file mode 100644 index 0000000000..8a0f5f2048 --- /dev/null +++ b/spec/support/shared_examples/a_model_with_an_address.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +shared_examples_for "a model with an address" do + it { should normalize(:address_line_1).from(nil).to(nil) } + it { should normalize(:address_line_1).from("").to(nil) } + it { should normalize(:address_line_2).from(nil).to(nil) } + it { should normalize(:address_line_2).from("").to(nil) } + it { should normalize(:address_town).from(nil).to(nil) } + it { should normalize(:address_town).from("").to(nil) } + it { should normalize(:address_postcode).from(nil).to(nil) } + it { should normalize(:address_postcode).from("").to(nil) } + it { should normalize(:address_postcode).from(" SW111AA ").to("SW11 1AA") } +end From eb23ef48cb44df79126ca7c5fc54079ab1b7e212 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 09:51:03 +0100 Subject: [PATCH 30/74] Remove `job_id` This is not being used anywhere so it can be safely deleted. Jira-Issue: MAV-7288 --- app/jobs/immunisations_api_job.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/jobs/immunisations_api_job.rb b/app/jobs/immunisations_api_job.rb index 1b19630d74..1a8df3985d 100644 --- a/app/jobs/immunisations_api_job.rb +++ b/app/jobs/immunisations_api_job.rb @@ -5,6 +5,4 @@ class ImmunisationsAPIJob include Sidekiq::Throttled::Job sidekiq_throttle_as :immunisations_api - - def job_id = Sidekiq::Context.current["jid"] end From 0632da6937a74f76a06bab6e71d41bd84d36ac51 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 09:51:48 +0100 Subject: [PATCH 31/74] Rename throttling concerns This renames the concerns to match the throttling key being used. Jira-Issue: MAV-7288 --- ...throttling_concern.rb => govuk_notify_throttling_concern.rb} | 2 +- ...{pds_api_throttling_concern.rb => pds_throttling_concern.rb} | 2 +- app/jobs/email_delivery_job.rb | 2 +- app/jobs/patient_nhs_number_lookup_job.rb | 2 +- app/jobs/patient_update_from_pds_job.rb | 2 +- app/jobs/pds_cascading_search_job.rb | 2 +- app/jobs/process_consent_form_job.rb | 2 +- app/jobs/sms_delivery_job.rb | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename app/jobs/concerns/{notify_throttling_concern.rb => govuk_notify_throttling_concern.rb} (85%) rename app/jobs/concerns/{pds_api_throttling_concern.rb => pds_throttling_concern.rb} (88%) diff --git a/app/jobs/concerns/notify_throttling_concern.rb b/app/jobs/concerns/govuk_notify_throttling_concern.rb similarity index 85% rename from app/jobs/concerns/notify_throttling_concern.rb rename to app/jobs/concerns/govuk_notify_throttling_concern.rb index 2184c84929..53b2d19ffc 100644 --- a/app/jobs/concerns/notify_throttling_concern.rb +++ b/app/jobs/concerns/govuk_notify_throttling_concern.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module NotifyThrottlingConcern +module GovukNotifyThrottlingConcern extend ActiveSupport::Concern include Sidekiq::Job diff --git a/app/jobs/concerns/pds_api_throttling_concern.rb b/app/jobs/concerns/pds_throttling_concern.rb similarity index 88% rename from app/jobs/concerns/pds_api_throttling_concern.rb rename to app/jobs/concerns/pds_throttling_concern.rb index 3353a4854a..4e730e511a 100644 --- a/app/jobs/concerns/pds_api_throttling_concern.rb +++ b/app/jobs/concerns/pds_throttling_concern.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module PDSAPIThrottlingConcern +module PDSThrottlingConcern extend ActiveSupport::Concern include Sidekiq::Job diff --git a/app/jobs/email_delivery_job.rb b/app/jobs/email_delivery_job.rb index ec579135b1..2252c7c860 100644 --- a/app/jobs/email_delivery_job.rb +++ b/app/jobs/email_delivery_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class EmailDeliveryJob < NotifyDeliveryJob - include NotifyThrottlingConcern + include GovukNotifyThrottlingConcern PASSTHROUGH_TEMPLATE_ID = "305a53f8-86eb-485e-85a5-328c9aabba45" diff --git a/app/jobs/patient_nhs_number_lookup_job.rb b/app/jobs/patient_nhs_number_lookup_job.rb index 86a8878796..cab3c605c9 100644 --- a/app/jobs/patient_nhs_number_lookup_job.rb +++ b/app/jobs/patient_nhs_number_lookup_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class PatientNHSNumberLookupJob < ApplicationJob - include PDSAPIThrottlingConcern + include PDSThrottlingConcern queue_as :pds diff --git a/app/jobs/patient_update_from_pds_job.rb b/app/jobs/patient_update_from_pds_job.rb index 6eb6bfbf9f..de4d3b08c2 100644 --- a/app/jobs/patient_update_from_pds_job.rb +++ b/app/jobs/patient_update_from_pds_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class PatientUpdateFromPDSJob < ApplicationJob - include PDSAPIThrottlingConcern + include PDSThrottlingConcern queue_as :pds diff --git a/app/jobs/pds_cascading_search_job.rb b/app/jobs/pds_cascading_search_job.rb index 2415259645..6c1a8f92dc 100644 --- a/app/jobs/pds_cascading_search_job.rb +++ b/app/jobs/pds_cascading_search_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class PDSCascadingSearchJob < ApplicationJob - include PDSAPIThrottlingConcern + include PDSThrottlingConcern queue_as :pds diff --git a/app/jobs/process_consent_form_job.rb b/app/jobs/process_consent_form_job.rb index 3325514408..cb1c3ae664 100644 --- a/app/jobs/process_consent_form_job.rb +++ b/app/jobs/process_consent_form_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ProcessConsentFormJob < ApplicationJob - include PDSAPIThrottlingConcern + include PDSThrottlingConcern queue_as :consents diff --git a/app/jobs/sms_delivery_job.rb b/app/jobs/sms_delivery_job.rb index 29627e0b8a..ed85cd5b6c 100644 --- a/app/jobs/sms_delivery_job.rb +++ b/app/jobs/sms_delivery_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SMSDeliveryJob < NotifyDeliveryJob - include NotifyThrottlingConcern + include GovukNotifyThrottlingConcern PASSTHROUGH_TEMPLATE_ID = "c242b359-73d6-4b74-bda2-136093550636" From 37588a4d0a5190dbd0d6432c00dcf925f458a499 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 09:53:45 +0100 Subject: [PATCH 32/74] Add `ImmunisationsAPIThrottlingConcern` This adds a concern following a similar pattern we have for all the other concerns related to throttling. Jira-Issue: MAV-7288 --- .../concerns/immunisations_api_throttling_concern.rb | 10 ++++++++++ app/jobs/immunisations_api_job.rb | 8 -------- app/jobs/search_vaccination_records_in_nhs_job.rb | 5 ++++- app/jobs/sync_vaccination_record_to_nhs_job.rb | 5 ++++- 4 files changed, 18 insertions(+), 10 deletions(-) create mode 100644 app/jobs/concerns/immunisations_api_throttling_concern.rb delete mode 100644 app/jobs/immunisations_api_job.rb diff --git a/app/jobs/concerns/immunisations_api_throttling_concern.rb b/app/jobs/concerns/immunisations_api_throttling_concern.rb new file mode 100644 index 0000000000..e54b5f6f99 --- /dev/null +++ b/app/jobs/concerns/immunisations_api_throttling_concern.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module ImmunisationsAPIThrottlingConcern + extend ActiveSupport::Concern + + include Sidekiq::Job + include Sidekiq::Throttled::Job + + included { sidekiq_throttle_as :immunisations_api } +end diff --git a/app/jobs/immunisations_api_job.rb b/app/jobs/immunisations_api_job.rb deleted file mode 100644 index 1a8df3985d..0000000000 --- a/app/jobs/immunisations_api_job.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class ImmunisationsAPIJob - include Sidekiq::Job - include Sidekiq::Throttled::Job - - sidekiq_throttle_as :immunisations_api -end diff --git a/app/jobs/search_vaccination_records_in_nhs_job.rb b/app/jobs/search_vaccination_records_in_nhs_job.rb index 84ca37acf6..06a64ff4ba 100644 --- a/app/jobs/search_vaccination_records_in_nhs_job.rb +++ b/app/jobs/search_vaccination_records_in_nhs_job.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true -class SearchVaccinationRecordsInNHSJob < ImmunisationsAPIJob +class SearchVaccinationRecordsInNHSJob + include Sidekiq::Job + include ImmunisationsAPIThrottlingConcern + sidekiq_options queue: :immunisations_api_search ACADEMIC_YEAR_2025_CUTOFF_DATE = 2025.to_academic_year_date_range.first.freeze diff --git a/app/jobs/sync_vaccination_record_to_nhs_job.rb b/app/jobs/sync_vaccination_record_to_nhs_job.rb index 28ed647b9a..5bc99f4d87 100644 --- a/app/jobs/sync_vaccination_record_to_nhs_job.rb +++ b/app/jobs/sync_vaccination_record_to_nhs_job.rb @@ -1,6 +1,9 @@ # frozen_string_literal: true -class SyncVaccinationRecordToNHSJob < ImmunisationsAPIJob +class SyncVaccinationRecordToNHSJob + include Sidekiq::Job + include ImmunisationsAPIThrottlingConcern + sidekiq_options queue: :immunisations_api_sync, lock: :until_and_while_executing From cb502113e08dcc6815aefc41415295cc27d0e7ea Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 28 Apr 2026 20:24:48 +0100 Subject: [PATCH 33/74] Refactor job concerns to not contain Active Job This is in preparation for us to move away from using Active Job and instead to use the Sidekiq API directly when defining and enqueuing jobs. To allow for a zero-downtime migration, there will need to be some period of time where we have both the Sidekiq and Active Job classes in the code, where new jobs are queued using the Sidekiq variation as we wait for old Active Job jobs to finish. To support this, I've removed anything that's related to Active Job from the shared concerns and moved them in to the specific job classes, allowing us to use these concerns in both the Sidekiq and Active Job variants. Jira-Issue: MAV-7288 --- app/jobs/concerns/govuk_notify_throttling_concern.rb | 6 +----- app/jobs/concerns/pds_throttling_concern.rb | 6 +----- .../concerns/send_school_consent_notification_concern.rb | 2 -- app/jobs/notify_delivery_job.rb | 2 ++ app/jobs/patient_nhs_number_lookup_job.rb | 1 + app/jobs/patient_update_from_pds_job.rb | 1 + app/jobs/pds_cascading_search_job.rb | 1 + app/jobs/process_consent_form_job.rb | 1 + app/jobs/send_automatic_school_consent_reminders_job.rb | 2 ++ app/jobs/send_manual_school_consent_reminders_job.rb | 2 ++ app/jobs/send_school_consent_requests_job.rb | 2 ++ 11 files changed, 14 insertions(+), 12 deletions(-) diff --git a/app/jobs/concerns/govuk_notify_throttling_concern.rb b/app/jobs/concerns/govuk_notify_throttling_concern.rb index 53b2d19ffc..868f2da050 100644 --- a/app/jobs/concerns/govuk_notify_throttling_concern.rb +++ b/app/jobs/concerns/govuk_notify_throttling_concern.rb @@ -6,9 +6,5 @@ module GovukNotifyThrottlingConcern include Sidekiq::Job include Sidekiq::Throttled::Job - included do - sidekiq_throttle_as :govuk_notify - - queue_as :notifications - end + included { sidekiq_throttle_as :govuk_notify } end diff --git a/app/jobs/concerns/pds_throttling_concern.rb b/app/jobs/concerns/pds_throttling_concern.rb index 4e730e511a..0c973770be 100644 --- a/app/jobs/concerns/pds_throttling_concern.rb +++ b/app/jobs/concerns/pds_throttling_concern.rb @@ -6,9 +6,5 @@ module PDSThrottlingConcern include Sidekiq::Job include Sidekiq::Throttled::Job - included do - sidekiq_throttle_as :pds - - retry_on Faraday::ServerError, wait: :polynomially_longer - end + included { sidekiq_throttle_as :pds } end diff --git a/app/jobs/concerns/send_school_consent_notification_concern.rb b/app/jobs/concerns/send_school_consent_notification_concern.rb index 719846c5e8..091c6b388d 100644 --- a/app/jobs/concerns/send_school_consent_notification_concern.rb +++ b/app/jobs/concerns/send_school_consent_notification_concern.rb @@ -3,8 +3,6 @@ module SendSchoolConsentNotificationConcern extend ActiveSupport::Concern - included { queue_as :notifications } - def patient_programmes_eligible_for_notification(session:) return unless session.school? && session.can_receive_consent? diff --git a/app/jobs/notify_delivery_job.rb b/app/jobs/notify_delivery_job.rb index feb45ff298..a069253b58 100644 --- a/app/jobs/notify_delivery_job.rb +++ b/app/jobs/notify_delivery_job.rb @@ -6,6 +6,8 @@ class NotifyDeliveryJob < ApplicationJob TEAM_ONLY_API_KEY_MESSAGE = "Can’t send to this recipient using a team-only API key" + queue_as :notifications + def self.client @client ||= Notifications::Client.new( diff --git a/app/jobs/patient_nhs_number_lookup_job.rb b/app/jobs/patient_nhs_number_lookup_job.rb index cab3c605c9..4be3ce3d65 100644 --- a/app/jobs/patient_nhs_number_lookup_job.rb +++ b/app/jobs/patient_nhs_number_lookup_job.rb @@ -4,6 +4,7 @@ class PatientNHSNumberLookupJob < ApplicationJob include PDSThrottlingConcern queue_as :pds + retry_on Faraday::ServerError, wait: :polynomially_longer def perform(patient) return if patient.nhs_number.present? && !patient.invalidated? diff --git a/app/jobs/patient_update_from_pds_job.rb b/app/jobs/patient_update_from_pds_job.rb index de4d3b08c2..5aa0beb7c3 100644 --- a/app/jobs/patient_update_from_pds_job.rb +++ b/app/jobs/patient_update_from_pds_job.rb @@ -4,6 +4,7 @@ class PatientUpdateFromPDSJob < ApplicationJob include PDSThrottlingConcern queue_as :pds + retry_on Faraday::ServerError, wait: :polynomially_longer def perform(patient, search_results = []) raise MissingNHSNumber if patient.nhs_number.nil? && search_results.empty? diff --git a/app/jobs/pds_cascading_search_job.rb b/app/jobs/pds_cascading_search_job.rb index 6c1a8f92dc..bce0fafeae 100644 --- a/app/jobs/pds_cascading_search_job.rb +++ b/app/jobs/pds_cascading_search_job.rb @@ -4,6 +4,7 @@ class PDSCascadingSearchJob < ApplicationJob include PDSThrottlingConcern queue_as :pds + retry_on Faraday::ServerError, wait: :polynomially_longer def perform(searchable, step_name: nil, search_results: [], queue: :pds) step_name ||= :no_fuzzy_with_history diff --git a/app/jobs/process_consent_form_job.rb b/app/jobs/process_consent_form_job.rb index cb1c3ae664..2c3df4027a 100644 --- a/app/jobs/process_consent_form_job.rb +++ b/app/jobs/process_consent_form_job.rb @@ -4,6 +4,7 @@ class ProcessConsentFormJob < ApplicationJob include PDSThrottlingConcern queue_as :consents + retry_on Faraday::ServerError, wait: :polynomially_longer # We may enqueue this job more than once for the same ConsentForm during the parent # consent journey (e.g. once when the consent is recorded, and again after the optional diff --git a/app/jobs/send_automatic_school_consent_reminders_job.rb b/app/jobs/send_automatic_school_consent_reminders_job.rb index a30543cc54..03a5a89b81 100644 --- a/app/jobs/send_automatic_school_consent_reminders_job.rb +++ b/app/jobs/send_automatic_school_consent_reminders_job.rb @@ -3,6 +3,8 @@ class SendAutomaticSchoolConsentRemindersJob < ApplicationJob include SendSchoolConsentNotificationConcern + queue_as :notifications + def perform(session) patient_programmes_eligible_for_notification( session: diff --git a/app/jobs/send_manual_school_consent_reminders_job.rb b/app/jobs/send_manual_school_consent_reminders_job.rb index 9850a7770e..57c82582c2 100644 --- a/app/jobs/send_manual_school_consent_reminders_job.rb +++ b/app/jobs/send_manual_school_consent_reminders_job.rb @@ -3,6 +3,8 @@ class SendManualSchoolConsentRemindersJob < ApplicationJob include SendSchoolConsentNotificationConcern + queue_as :notifications + def perform(session, current_user:) patient_programmes_eligible_for_notification( session: diff --git a/app/jobs/send_school_consent_requests_job.rb b/app/jobs/send_school_consent_requests_job.rb index d8a978c88f..d808b86fd4 100644 --- a/app/jobs/send_school_consent_requests_job.rb +++ b/app/jobs/send_school_consent_requests_job.rb @@ -3,6 +3,8 @@ class SendSchoolConsentRequestsJob < ApplicationJob include SendSchoolConsentNotificationConcern + queue_as :notifications + def perform(session) patients_and_programmes(session) do |patient, programmes| patient.notifier.send_consent_request(programmes, session:, sent_by: nil) From d7e2184bb6b97a3bbeca5aaf4b20257374bc1d43 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 8 Apr 2026 18:48:37 +0100 Subject: [PATCH 34/74] Moving logic to validations Bubbling up the the running conditions of the validations away from the validation logic itself. Co-authored-by: Chris Roos Jira-Issue: MAV-7090 --- app/models/concerns/csv_importable.rb | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 40f84b4283..bc9a23b3b2 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -75,10 +75,12 @@ module CSVImportable } validates :csv_filename, presence: true - validate :csv_is_valid - validate :csv_has_records - validate :csv_is_not_too_large - validate :rows_are_valid + validate :csv_is_valid, unless: -> { csv_removed? } + validate :csv_has_records, + if: -> { !csv_removed? && csv_data_object.well_formed? } + validate :csv_is_not_too_large, + unless: -> { csv_removed? || csv_data_object.empty? } + validate :rows_are_valid, if: -> { !csv_removed? && rows } before_save :ensure_processed_with_count_statistics end @@ -161,16 +163,12 @@ def csv_is_valid end def csv_is_not_too_large - return unless csv_data - if rows_count > MAX_CSV_ROWS errors.add(:csv, :too_many_rows, count: MAX_CSV_ROWS) end end def csv_has_records - return unless csv_data - csv_has_no_records = csv_data_object.empty? || (csv_data_object.count == 1 && csv_data_object.has_instruction_row?) @@ -178,8 +176,6 @@ def csv_has_records end def rows_are_valid - return unless rows - rows.each(&:validate) check_rows_are_unique From 185d0448bb5a9a6311ad39e34a4103ce070921e0 Mon Sep 17 00:00:00 2001 From: Chris Roos Date: Mon, 20 Apr 2026 16:08:35 +0100 Subject: [PATCH 35/74] Add specs for PatientImport#check_rows_are_unique These are currently duplicated across class_import and cohort_import specs but we can look at removing that later. This is in preparation for moving this check out of `#rows_are_valid` and into their own validation. Co-authored-by: Misha Gorodnitzky Jira-Issue: MAV-7090 --- app/models/patient_import.rb | 1 - .../class_import/duplicate_nhs_numbers.csv | 3 +++ spec/models/class_import_spec.rb | 21 +++++++++++++++++++ spec/models/cohort_import_spec.rb | 21 +++++++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 spec/fixtures/files/class_import/duplicate_nhs_numbers.csv diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index fb31e1885c..65fcdb2104 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -184,7 +184,6 @@ def enqueue_pds_cascading_searches(changesets) end # TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation. - # TODO: Currently entested, unlike the equivalent in ImmunisationImport. Add tests. def check_rows_are_unique rows .map(&:nhs_number_value) diff --git a/spec/fixtures/files/class_import/duplicate_nhs_numbers.csv b/spec/fixtures/files/class_import/duplicate_nhs_numbers.csv new file mode 100644 index 0000000000..b9ce04fd3a --- /dev/null +++ b/spec/fixtures/files/class_import/duplicate_nhs_numbers.csv @@ -0,0 +1,3 @@ +CHILD_SCHOOL_URN,PARENT_1_NAME,PARENT_1_RELATIONSHIP,PARENT_1_EMAIL,PARENT_1_PHONE,PARENT_2_NAME,PARENT_2_RELATIONSHIP,PARENT_2_EMAIL,PARENT_2_PHONE,CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_PREFERRED_GIVEN_NAME,CHILD_DATE_OF_BIRTH,CHILD_ADDRESS_LINE_1,CHILD_ADDRESS_LINE_2,CHILD_TOWN,CHILD_POSTCODE,CHILD_NHS_NUMBER +123456,Peter,Dad,,,,,,,Jennifer,Clarke,Jenny,2010-01-01,10 Downing Street,,London,SW1A 1AA,9990000018 +123456,Claire,Mum,,,,,,,Jennifer,Clarke,Jenny,2010-01-01,10 Downing Street,,London,SW1A 1AA,9990000018 diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb index dbaad17604..e1eb90a463 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -131,6 +131,27 @@ expect(class_import.errors.to_a[0]).to start_with("Row 2") end end + + describe "with duplicate nhs numbers" do + let(:file) { "duplicate_nhs_numbers.csv" } + + it "has 2 rows" do + expect(class_import.rows.count).to eq(2) + end + + it "is not valid" do + expect(class_import).not_to be_valid + end + + it "includes the duplicate nhs error number on both rows" do + expect(class_import.rows.first.errors.first.type).to match( + /The same NHS number appears multiple times in this file/ + ) + expect(class_import.rows.last.errors.first.type).to match( + /The same NHS number appears multiple times in this file/ + ) + end + end end describe "#process!" do diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index 724679489e..a67c7706af 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -132,6 +132,27 @@ expect(cohort_import.errors.to_a[0]).to start_with("Row 2") end end + + describe "with duplicate nhs numbers" do + let(:file) { "duplicate_nhs_numbers.csv" } + + it "has 2 rows" do + expect(cohort_import.rows.count).to eq(2) + end + + it "is not valid" do + expect(cohort_import).not_to be_valid + end + + it "includes the duplicate nhs error number on both rows" do + expect(cohort_import.rows.first.errors.first.type).to match( + /The same NHS number appears multiple times in this file/ + ) + expect(cohort_import.rows.last.errors.first.type).to match( + /The same NHS number appears multiple times in this file/ + ) + end + end end describe "#process!" do From 796eaf552224283563018e07c1f057d82c85fdc9 Mon Sep 17 00:00:00 2001 From: Chris Roos Date: Wed, 22 Apr 2026 15:08:22 +0100 Subject: [PATCH 36/74] Add specs for aggregating row-level errors I tried to add these tests to the `a_csv_importable_model` shared examples, but each of the 3 upload formats are different so I had to add them to the individual import specs instead. Writing these tests has highlighted what I believe to be an odd use of `ActiveModel::Errors#add` in `CSVImportable#rows_are_valid` where we're supplying an array of `formatted_errors` to the `type` parameter. There's then logic in `AppImportErrorsComponent` to handle this special case. I think a more idiomatic user would be to add each error to `row_N` as a string. _If_ we decide to change this then we'll need to be mindful that these errors are serialized as `serialized_errors` in the database so we'll either have to update those existing records, or continue to handle the `type` being an array. Co-authored-by: Misha Gorodnitzky Jira-Issue: MAV-7090 --- .../invalid_with_multiple_errors_per_row.csv | 2 ++ .../invalid_with_multiple_errors_per_row.csv | 2 ++ .../invalid_with_multiple_errors_per_row.csv | 2 ++ spec/models/class_import_spec.rb | 15 +++++++++++++++ spec/models/cohort_import_spec.rb | 15 +++++++++++++++ spec/models/immunisation_import_spec.rb | 15 +++++++++++++++ 6 files changed, 51 insertions(+) create mode 100644 spec/fixtures/files/class_import/invalid_with_multiple_errors_per_row.csv create mode 100644 spec/fixtures/files/cohort_import/invalid_with_multiple_errors_per_row.csv create mode 100644 spec/fixtures/files/immunisation_import/point_of_care/invalid_with_multiple_errors_per_row.csv diff --git a/spec/fixtures/files/class_import/invalid_with_multiple_errors_per_row.csv b/spec/fixtures/files/class_import/invalid_with_multiple_errors_per_row.csv new file mode 100644 index 0000000000..093e138784 --- /dev/null +++ b/spec/fixtures/files/class_import/invalid_with_multiple_errors_per_row.csv @@ -0,0 +1,2 @@ +CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_DATE_OF_BIRTH +first-name,, diff --git a/spec/fixtures/files/cohort_import/invalid_with_multiple_errors_per_row.csv b/spec/fixtures/files/cohort_import/invalid_with_multiple_errors_per_row.csv new file mode 100644 index 0000000000..acba826b30 --- /dev/null +++ b/spec/fixtures/files/cohort_import/invalid_with_multiple_errors_per_row.csv @@ -0,0 +1,2 @@ +CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_DATE_OF_BIRTH,CHILD_POSTCODE,CHILD_SCHOOL_URN +Jennifer,,,SW1A 1AA,123456 diff --git a/spec/fixtures/files/immunisation_import/point_of_care/invalid_with_multiple_errors_per_row.csv b/spec/fixtures/files/immunisation_import/point_of_care/invalid_with_multiple_errors_per_row.csv new file mode 100644 index 0000000000..e98998de3d --- /dev/null +++ b/spec/fixtures/files/immunisation_import/point_of_care/invalid_with_multiple_errors_per_row.csv @@ -0,0 +1,2 @@ +ORGANISATION_CODE,SCHOOL_URN,SCHOOL_NAME,NHS_NUMBER,PERSON_FORENAME,PERSON_SURNAME,PERSON_DOB,PERSON_GENDER_CODE,PERSON_POSTCODE,VACCINATED,DATE_OF_VACCINATION,PROGRAMME,VACCINE_GIVEN,BATCH_NUMBER,BATCH_EXPIRY_DATE,ANATOMICAL_SITE,PERFORMING_PROFESSIONAL_FORENAME,PERFORMING_PROFESSIONAL_SURNAME,REASON_NOT_VACCINATED,CONSENT_TYPE,LOCAL_PATIENT_ID,LOCAL_PATIENT_ID_URI +org-code,888888,school-name,0123456789,person-forename,person-surname,20000101,unknown,sw1a1aa,N,20240514,HPV,AstraZeneca Fluenz,,,,Vaccinator1,Name1,,Parental Consent,LocalPatient1,www.LocalPatient1 diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb index e1eb90a463..f2bc09ec28 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -152,6 +152,21 @@ ) end end + + describe "with a row containing multiple errors" do + let(:file) { "invalid_with_multiple_errors_per_row.csv" } + + it "aggregates the errors against the row" do + expect(class_import).not_to be_valid + expect(class_import.errors[:row_2][0].length).to eq(2) + expect(class_import.errors[:row_2][0]).to include( + "CHILD_DATE_OF_BIRTH: Enter a date of birth." + ) + expect(class_import.errors[:row_2][0]).to include( + "CHILD_LAST_NAME: Enter a last name." + ) + end + end end describe "#process!" do diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index a67c7706af..81c5a66f86 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -153,6 +153,21 @@ ) end end + + describe "with a row containing multiple errors" do + let(:file) { "invalid_with_multiple_errors_per_row.csv" } + + it "aggregates the errors against the row" do + expect(cohort_import).not_to be_valid + expect(cohort_import.errors[:row_2][0].length).to eq(2) + expect(cohort_import.errors[:row_2][0]).to include( + "CHILD_DATE_OF_BIRTH: Enter a date of birth." + ) + expect(cohort_import.errors[:row_2][0]).to include( + "CHILD_LAST_NAME: Enter a last name." + ) + end + end end describe "#process!" do diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 93546d82f0..c45e1b4d06 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -189,6 +189,21 @@ expect(immunisation_import.errors).to include(:row_3, :row_4) end end + + describe "with a row containing multiple errors" do + let(:file) { "invalid_with_multiple_errors_per_row.csv" } + + it "aggregates the errors against the row" do + expect(immunisation_import).not_to be_valid + expect(immunisation_import.errors[:row_2][0].length).to eq(2) + expect(immunisation_import.errors[:row_2][0]).to include( + "DATE_OF_VACCINATION: must be in the current academic year" + ) + expect(immunisation_import.errors[:row_2][0]).to include( + "REASON_NOT_VACCINATED: Enter a valid reason." + ) + end + end end describe "#process!" do From 38596112dcb2a546414d99399524e604b62d5d76 Mon Sep 17 00:00:00 2001 From: Chris Roos Date: Mon, 20 Apr 2026 16:09:36 +0100 Subject: [PATCH 37/74] Extract validators for import row uniqueness These validators were a bit harder to follow with sub-classes, using separate validation classes separates the concerns, and seems easier to read even with the conditions added to the validats lines. To make this work, we also needed to separate out the aggregation of the errors into a after_validation hook. Co-authored-by: Misha Gorodnitzky Jira-Issue: MAV-7090 --- app/models/concerns/csv_importable.rb | 38 +++++++++------ app/models/immunisation_import.rb | 34 -------------- app/models/patient_import.rb | 19 -------- ...s_all_immunisation_attributes_validator.rb | 47 +++++++++++++++++++ .../import/rows_unique_by_nhs_number.rb | 31 ++++++++++++ 5 files changed, 101 insertions(+), 68 deletions(-) create mode 100644 app/validators/import/rows_unique_across_all_immunisation_attributes_validator.rb create mode 100644 app/validators/import/rows_unique_by_nhs_number.rb diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index bc9a23b3b2..1effc19c24 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -82,6 +82,14 @@ module CSVImportable unless: -> { csv_removed? || csv_data_object.empty? } validate :rows_are_valid, if: -> { !csv_removed? && rows } + validates_with Import::RowsUniqueAcrossAllImmunisationAttributesValidator, + if: -> { is_a?(ImmunisationImport) && !csv_removed? && rows } + validates_with Import::RowsUniqueByNHSNumber, + if: -> { is_a?(PatientImport) && !csv_removed? && rows } + + after_validation :aggregate_row_level_errors, + if: -> { !csv_removed? && rows } + before_save :ensure_processed_with_count_statistics end @@ -177,9 +185,23 @@ def csv_has_records def rows_are_valid rows.each(&:validate) + end - check_rows_are_unique + def count_columns + %i[ + new_record_count + changed_record_count + exact_duplicate_record_count + ].freeze + end + + def ensure_processed_with_count_statistics + if processed_at? && count_columns.any? { |column| send(column).nil? } + raise "Count statistics must be set for a processed import." + end + end + def aggregate_row_level_errors row_offset = csv_data_object.has_instruction_row? ? 3 : 2 rows.each.with_index do |row, index| @@ -200,18 +222,4 @@ def rows_are_valid errors.add("row_#{index + row_offset}".to_sym, formatted_errors) end end - - def count_columns - %i[ - new_record_count - changed_record_count - exact_duplicate_record_count - ].freeze - end - - def ensure_processed_with_count_statistics - if processed_at? && count_columns.any? { |column| send(column).nil? } - raise "Count statistics must be set for a processed import." - end - end end diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 9d4597ab3b..cf17eb5d5c 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -93,40 +93,6 @@ def process! private - # TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation. - def check_rows_are_unique - row_offset = csv_data_object.has_instruction_row? ? 3 : 2 - - rows - .map(&:full_row_deduplication_attributes) - .tally - .each do |full_row_deduplication_attributes, count| - next if count <= 1 - - matching_rows = - rows.each_with_index.select do |row, _index| - row.full_row_deduplication_attributes == - full_row_deduplication_attributes - end - matching_rows = matching_rows.to_h - - matching_rows.each_key do |row| - other_row_numbers = - matching_rows - .reject { |other_row, _| other_row.equal?(row) } - .map { |_, other_index| other_index + row_offset } - - other_rows_text = - "#{"row".pluralize(other_row_numbers.size)} #{other_row_numbers.to_sentence(last_word_connector: " and ")}" - - row.errors.add( - :base, - "The record on this row appears to be a duplicate of #{other_rows_text}." - ) - end - end - end - def parse_row(data) ImmunisationImportRow.new(data:, team:, type:) end diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index 65fcdb2104..c4c7315c50 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -183,25 +183,6 @@ def enqueue_pds_cascading_searches(changesets) end end - # TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation. - def check_rows_are_unique - rows - .map(&:nhs_number_value) - .tally - .each do |nhs_number, count| - next if nhs_number.nil? || count <= 1 - - rows - .select { _1.nhs_number_value == nhs_number } - .each do |row| - row.errors.add( - :base, - "The same NHS number appears multiple times in this file." - ) - end - end - end - def valid_pds_match_rate? pds_match_rate / 100 >= PDS_MATCH_THRESHOLD end diff --git a/app/validators/import/rows_unique_across_all_immunisation_attributes_validator.rb b/app/validators/import/rows_unique_across_all_immunisation_attributes_validator.rb new file mode 100644 index 0000000000..e343de7de9 --- /dev/null +++ b/app/validators/import/rows_unique_across_all_immunisation_attributes_validator.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Import + class RowsUniqueAcrossAllImmunisationAttributesValidator < ActiveModel::Validator + def validate(record) + check_rows(record) + end + + private + + def check_rows(record) + row_offset = record.csv_data_object.has_instruction_row? ? 3 : 2 + + record + .rows + .map(&:full_row_deduplication_attributes) + .tally + .each do |full_row_deduplication_attributes, count| + next if count <= 1 + + matching_rows = + record.rows.each_with_index.select do |row, _index| + row.full_row_deduplication_attributes == + full_row_deduplication_attributes + end + matching_rows = matching_rows.to_h + + matching_rows.each_key do |row| + other_row_numbers = + matching_rows + .reject { |other_row, _| other_row.equal?(row) } + .map { |_, other_index| other_index + row_offset } + + rows_text = "row".pluralize(other_row_numbers.size) + other_row_numbers_text = + other_row_numbers.to_sentence(last_word_connector: " and ") + other_rows_text = "#{rows_text} #{other_row_numbers_text}" + + row.errors.add( + :base, + "The record on this row appears to be a duplicate of #{other_rows_text}." + ) + end + end + end + end +end diff --git a/app/validators/import/rows_unique_by_nhs_number.rb b/app/validators/import/rows_unique_by_nhs_number.rb new file mode 100644 index 0000000000..29da6894b4 --- /dev/null +++ b/app/validators/import/rows_unique_by_nhs_number.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Import + class RowsUniqueByNHSNumber < ActiveModel::Validator + def validate(record) + check_rows(record) + end + + private + + def check_rows(record) + record + .rows + .map(&:nhs_number_value) + .tally + .each do |nhs_number, count| + next if nhs_number.nil? || count <= 1 + + record + .rows + .select { _1.nhs_number_value == nhs_number } + .each do |row| + row.errors.add( + :base, + "The same NHS number appears multiple times in this file." + ) + end + end + end + end +end From 54cb0cae5095b28c2c6a19f5709fe0ff8c9e74fb Mon Sep 17 00:00:00 2001 From: Chris Roos Date: Tue, 21 Apr 2026 11:52:57 +0100 Subject: [PATCH 38/74] Avoid validating twice in *ImportsController It's more idiomatic Rails to rely on the boolean return value of `#save` rather than explicitly calling `#invalid?` and `#save!`. This also avoids firing the validations twice in the controllers and the ProcessImportJob. Co-authored-by: Misha Gorodnitzky Jira-Issue: MAV-7090 --- app/controllers/class_imports_controller.rb | 10 ++++------ app/controllers/cohort_imports_controller.rb | 10 ++++------ app/controllers/immunisation_imports_controller.rb | 10 ++++------ app/jobs/process_import_job.rb | 2 +- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/app/controllers/class_imports_controller.rb b/app/controllers/class_imports_controller.rb index 7b18fb8e28..d33c02922e 100644 --- a/app/controllers/class_imports_controller.rb +++ b/app/controllers/class_imports_controller.rb @@ -28,14 +28,12 @@ def create **class_import_params ) - if @class_import.invalid? + if @class_import.save + ProcessImportJob.perform_later(@class_import) + redirect_to imports_path, flash: { success: "Import processing started" } + else render :new, status: :unprocessable_content and return end - - @class_import.save! - - ProcessImportJob.perform_later(@class_import) - redirect_to imports_path, flash: { success: "Import processing started" } end def show diff --git a/app/controllers/cohort_imports_controller.rb b/app/controllers/cohort_imports_controller.rb index c468c26db5..f4a7035220 100644 --- a/app/controllers/cohort_imports_controller.rb +++ b/app/controllers/cohort_imports_controller.rb @@ -26,14 +26,12 @@ def create **cohort_import_params ) - if @cohort_import.invalid? + if @cohort_import.save + ProcessImportJob.perform_later(@cohort_import) + redirect_to imports_path, flash: { success: "Import processing started" } + else render :new, status: :unprocessable_content and return end - - @cohort_import.save! - - ProcessImportJob.perform_later(@cohort_import) - redirect_to imports_path, flash: { success: "Import processing started" } end def show diff --git a/app/controllers/immunisation_imports_controller.rb b/app/controllers/immunisation_imports_controller.rb index fbc086ccd8..d6c07a3cdb 100644 --- a/app/controllers/immunisation_imports_controller.rb +++ b/app/controllers/immunisation_imports_controller.rb @@ -22,14 +22,12 @@ def create **immunisation_import_params ) - if @immunisation_import.invalid? + if @immunisation_import.save + ProcessImportJob.perform_later(@immunisation_import) + redirect_to imports_path, flash: { success: "Import processing started" } + else render :new, status: :unprocessable_content and return end - - @immunisation_import.save! - - ProcessImportJob.perform_later(@immunisation_import) - redirect_to imports_path, flash: { success: "Import processing started" } end def show diff --git a/app/jobs/process_import_job.rb b/app/jobs/process_import_job.rb index 05bb95761e..33416cfcb7 100644 --- a/app/jobs/process_import_job.rb +++ b/app/jobs/process_import_job.rb @@ -13,7 +13,7 @@ def perform(import) import.parse_rows! - return if import.invalid? + return if import.errors.any? return if import.rows_are_invalid? import.process! From e4498c145e0410f85932ebbd88a69d05ec74b7db Mon Sep 17 00:00:00 2001 From: Chris Roos Date: Tue, 21 Apr 2026 11:57:02 +0100 Subject: [PATCH 39/74] Only perform some validations on create in CSVImportable We only need these validations to check the uploaded CSV on create. Once it's been created, and the CSV has been saved in the database, we don't need to check these validations again. Co-authored-by: Misha Gorodnitzky Jira-Issue: MAV-7090 --- app/models/concerns/csv_importable.rb | 13 ++++++------- spec/factories/class_imports.rb | 4 +--- spec/factories/cohort_imports.rb | 4 +--- spec/factories/immunisation_imports.rb | 4 +--- 4 files changed, 9 insertions(+), 16 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 1effc19c24..74435e900b 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -75,18 +75,17 @@ module CSVImportable } validates :csv_filename, presence: true - validate :csv_is_valid, unless: -> { csv_removed? } - validate :csv_has_records, - if: -> { !csv_removed? && csv_data_object.well_formed? } - validate :csv_is_not_too_large, - unless: -> { csv_removed? || csv_data_object.empty? } - validate :rows_are_valid, if: -> { !csv_removed? && rows } + with_options on: :create do + validate :csv_is_valid + validate :csv_has_records, if: -> { csv_data_object.well_formed? } + validate :csv_is_not_too_large, unless: -> { csv_data_object.empty? } + end + validate :rows_are_valid, if: -> { !csv_removed? && rows } validates_with Import::RowsUniqueAcrossAllImmunisationAttributesValidator, if: -> { is_a?(ImmunisationImport) && !csv_removed? && rows } validates_with Import::RowsUniqueByNHSNumber, if: -> { is_a?(PatientImport) && !csv_removed? && rows } - after_validation :aggregate_row_level_errors, if: -> { !csv_removed? && rows } diff --git a/spec/factories/class_imports.rb b/spec/factories/class_imports.rb index 37723954bc..89989920b6 100644 --- a/spec/factories/class_imports.rb +++ b/spec/factories/class_imports.rb @@ -74,9 +74,7 @@ end trait :csv_removed do - csv_data { nil } - csv_filename { Faker::File.file_name(ext: "csv") } - csv_removed_at { Time.zone.now } + after(:create, &:remove!) end trait :pending do diff --git a/spec/factories/cohort_imports.rb b/spec/factories/cohort_imports.rb index 3982ac0bbc..b12c1ce2e1 100644 --- a/spec/factories/cohort_imports.rb +++ b/spec/factories/cohort_imports.rb @@ -67,9 +67,7 @@ end trait :csv_removed do - csv_data { nil } - csv_filename { Faker::File.file_name(ext: "csv") } - csv_removed_at { Time.zone.now } + after(:create, &:remove!) end trait :pending do diff --git a/spec/factories/immunisation_imports.rb b/spec/factories/immunisation_imports.rb index 0e2a8b16ec..9488adcdea 100644 --- a/spec/factories/immunisation_imports.rb +++ b/spec/factories/immunisation_imports.rb @@ -65,9 +65,7 @@ end trait :csv_removed do - csv_data { nil } - csv_filename { Faker::File.file_name(ext: "csv") } - csv_removed_at { Time.zone.now } + after(:create, &:remove!) end trait :pending do From c7dfc1226fac6985b5fa81d031d2f9e281f41e58 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 24 Apr 2026 12:05:15 +0100 Subject: [PATCH 40/74] Add parse_rows context to validations This replaces the existing conditions, !csv_removed? and rows, and makes reasoning about when these run more deterministic. Jira-Issue: MAV-7090 Co-authored-by: Chris Roos --- app/models/concerns/csv_importable.rb | 17 +++++++++-------- spec/models/class_import_spec.rb | 18 +++++++++--------- spec/models/cohort_import_spec.rb | 20 ++++++++++---------- spec/models/immunisation_import_spec.rb | 18 +++++++++--------- 4 files changed, 37 insertions(+), 36 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 74435e900b..60a0065e53 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -81,13 +81,14 @@ module CSVImportable validate :csv_is_not_too_large, unless: -> { csv_data_object.empty? } end - validate :rows_are_valid, if: -> { !csv_removed? && rows } - validates_with Import::RowsUniqueAcrossAllImmunisationAttributesValidator, - if: -> { is_a?(ImmunisationImport) && !csv_removed? && rows } - validates_with Import::RowsUniqueByNHSNumber, - if: -> { is_a?(PatientImport) && !csv_removed? && rows } - after_validation :aggregate_row_level_errors, - if: -> { !csv_removed? && rows } + with_options on: :parse_rows do + validate :rows_are_valid + validates_with Import::RowsUniqueAcrossAllImmunisationAttributesValidator, + if: -> { is_a?(ImmunisationImport) } + validates_with Import::RowsUniqueByNHSNumber, + if: -> { is_a?(PatientImport) } + after_validation :aggregate_row_level_errors + end before_save :ensure_processed_with_count_statistics end @@ -143,7 +144,7 @@ def parse_rows! self.rows = csv_data_object.records.map { |row_data| parse_row(row_data) } - if invalid? + if invalid?(:parse_rows) self.serialized_errors = errors.to_hash self.status = :rows_are_invalid save!(validate: false) diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb index f2bc09ec28..b5675d6b96 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -65,7 +65,7 @@ let(:file) { "invalid_fields.csv" } it "populates rows" do - expect(class_import).to be_invalid + expect(class_import).to be_invalid(:parse_rows) expect(class_import.rows).not_to be_empty end end @@ -74,7 +74,7 @@ let(:file) { "valid_extra_fields.csv" } it "populates rows" do - expect(class_import).to be_valid + expect(class_import).to be_valid(:parse_rows) end end @@ -82,7 +82,7 @@ let(:file) { "valid_instruction_row.csv" } it "populates rows" do - expect(class_import).to be_valid + expect(class_import).to be_valid(:parse_rows) expect(class_import.rows.count).to eq(1) end end @@ -91,7 +91,7 @@ let(:file) { "invalid_instruction_row.csv" } it "populates rows" do - expect(class_import).not_to be_valid + expect(class_import).not_to be_valid(:parse_rows) expect(class_import.rows.count).to eq(1) end @@ -105,7 +105,7 @@ let(:file) { "valid.csv" } it "is valid" do - expect(class_import).to be_valid + expect(class_import).to be_valid(:parse_rows) end end @@ -113,7 +113,7 @@ let(:file) { "valid_minimal.csv" } it "is valid" do - expect(class_import).to be_valid + expect(class_import).to be_valid(:parse_rows) expect(class_import.rows.count).to eq(1) end end @@ -122,7 +122,7 @@ let(:file) { "invalid_minimal.csv" } it "populates rows" do - expect(class_import).not_to be_valid + expect(class_import).not_to be_valid(:parse_rows) expect(class_import.rows.count).to eq(1) end @@ -140,7 +140,7 @@ end it "is not valid" do - expect(class_import).not_to be_valid + expect(class_import).not_to be_valid(:parse_rows) end it "includes the duplicate nhs error number on both rows" do @@ -157,7 +157,7 @@ let(:file) { "invalid_with_multiple_errors_per_row.csv" } it "aggregates the errors against the row" do - expect(class_import).not_to be_valid + expect(class_import).not_to be_valid(:parse_rows) expect(class_import.errors[:row_2][0].length).to eq(2) expect(class_import.errors[:row_2][0]).to include( "CHILD_DATE_OF_BIRTH: Enter a date of birth." diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index 81c5a66f86..2efef1ab70 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -62,12 +62,12 @@ let(:file) { "invalid_fields.csv" } it "populates rows" do - expect(cohort_import).to be_invalid + expect(cohort_import).to be_invalid(:parse_rows) expect(cohort_import.rows).not_to be_empty end it "is invalid" do - expect(cohort_import).not_to be_valid + expect(cohort_import).not_to be_valid(:parse_rows) end end @@ -75,7 +75,7 @@ let(:file) { "valid_extra_fields.csv" } it "populates rows" do - expect(cohort_import).to be_valid + expect(cohort_import).to be_valid(:parse_rows) end end @@ -83,7 +83,7 @@ let(:file) { "valid_instruction_row.csv" } it "populates rows" do - expect(cohort_import).to be_valid + expect(cohort_import).to be_valid(:parse_rows) expect(cohort_import.rows.count).to eq(1) end end @@ -92,7 +92,7 @@ let(:file) { "invalid_instruction_row.csv" } it "populates rows" do - expect(cohort_import).not_to be_valid + expect(cohort_import).not_to be_valid(:parse_rows) expect(cohort_import.rows.count).to eq(1) end @@ -106,7 +106,7 @@ let(:file) { "valid.csv" } it "is valid" do - expect(cohort_import).to be_valid + expect(cohort_import).to be_valid(:parse_rows) end end @@ -114,7 +114,7 @@ let(:file) { "valid_minimal.csv" } it "is valid" do - expect(cohort_import).to be_valid + expect(cohort_import).to be_valid(:parse_rows) expect(cohort_import.rows.count).to eq(1) end end @@ -123,7 +123,7 @@ let(:file) { "invalid_minimal.csv" } it "populates rows" do - expect(cohort_import).not_to be_valid + expect(cohort_import).not_to be_valid(:parse_rows) expect(cohort_import.rows.count).to eq(1) end @@ -141,7 +141,7 @@ end it "is not valid" do - expect(cohort_import).not_to be_valid + expect(cohort_import).not_to be_valid(:parse_rows) end it "includes the duplicate nhs error number on both rows" do @@ -158,7 +158,7 @@ let(:file) { "invalid_with_multiple_errors_per_row.csv" } it "aggregates the errors against the row" do - expect(cohort_import).not_to be_valid + expect(cohort_import).not_to be_valid(:parse_rows) expect(cohort_import.errors[:row_2][0].length).to eq(2) expect(cohort_import.errors[:row_2][0]).to include( "CHILD_DATE_OF_BIRTH: Enter a date of birth." diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index c45e1b4d06..e9644acba1 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -87,7 +87,7 @@ shared_examples "duplicate row" do it "is invalid" do - expect(immunisation_import).to be_invalid + expect(immunisation_import).to be_invalid(:parse_rows) expect(immunisation_import.rows.first.errors[:base]).to include( /The record on this row appears to be a duplicate of row 3\./ ) @@ -123,7 +123,7 @@ let(:file) { "valid_flu.csv" } it "populates the rows" do - expect(immunisation_import).to be_valid + expect(immunisation_import).to be_valid(:parse_rows) expect(immunisation_import.rows).not_to be_empty end end @@ -133,7 +133,7 @@ let(:file) { "valid_hpv.csv" } it "populates the rows" do - expect(immunisation_import).to be_valid + expect(immunisation_import).to be_valid(:parse_rows) expect(immunisation_import.rows).not_to be_empty end end @@ -143,7 +143,7 @@ let(:file) { "valid_mmr.csv" } it "populates the rows" do - expect(immunisation_import).to be_valid + expect(immunisation_import).to be_valid(:parse_rows) expect(immunisation_import.rows).not_to be_empty end end @@ -153,7 +153,7 @@ let(:file) { "valid_hpv_with_instruction_row.csv" } it "populates the rows" do - expect(immunisation_import).to be_valid + expect(immunisation_import).to be_valid(:parse_rows) expect(immunisation_import.rows).not_to be_empty end end @@ -163,7 +163,7 @@ let(:file) { "systm_one.csv" } it "populates the rows" do - expect(immunisation_import).to be_valid + expect(immunisation_import).to be_valid(:parse_rows) expect(immunisation_import.rows).not_to be_empty end end @@ -174,7 +174,7 @@ let(:test_date) { Date.new(2025, 12, 1) } it "populates the rows" do - expect(immunisation_import).to be_valid + expect(immunisation_import).to be_valid(:parse_rows) expect(immunisation_import.rows).not_to be_empty end end @@ -183,7 +183,7 @@ let(:file) { "invalid_rows.csv" } it "is invalid" do - expect(immunisation_import).to be_invalid + expect(immunisation_import).to be_invalid(:parse_rows) expect(immunisation_import.errors).not_to include(:row_1) # Header row expect(immunisation_import.errors).not_to include(:row_2) # Instruction row expect(immunisation_import.errors).to include(:row_3, :row_4) @@ -194,7 +194,7 @@ let(:file) { "invalid_with_multiple_errors_per_row.csv" } it "aggregates the errors against the row" do - expect(immunisation_import).not_to be_valid + expect(immunisation_import).not_to be_valid(:parse_rows) expect(immunisation_import.errors[:row_2][0].length).to eq(2) expect(immunisation_import.errors[:row_2][0]).to include( "DATE_OF_VACCINATION: must be in the current academic year" From 5f017e065b4469b0167f2c37da4715d89b5563ee Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 24 Apr 2026 12:33:58 +0100 Subject: [PATCH 41/74] Inline rows_are_valid validation Co-authored-by: Chris Roos Jira-Issue: MAV-7090 --- app/models/concerns/csv_importable.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 60a0065e53..7caadc63a9 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -82,7 +82,7 @@ module CSVImportable end with_options on: :parse_rows do - validate :rows_are_valid + validate { rows.each(&:validate) } validates_with Import::RowsUniqueAcrossAllImmunisationAttributesValidator, if: -> { is_a?(ImmunisationImport) } validates_with Import::RowsUniqueByNHSNumber, @@ -183,10 +183,6 @@ def csv_has_records errors.add(:csv, :empty) if csv_has_no_records end - def rows_are_valid - rows.each(&:validate) - end - def count_columns %i[ new_record_count From 603de9032e88f0e3c0d8d0f9ab612a95d10a6853 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 24 Apr 2026 15:35:46 +0100 Subject: [PATCH 42/74] Adding private section to CSVImportable Co-authored-by: Chris Roos Jira-Issue: MAV-7090 --- app/models/concerns/csv_importable.rb | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 7caadc63a9..9c4431350f 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -166,6 +166,22 @@ def load_serialized_errors!(limit: nil) end end + def count_columns + %i[ + new_record_count + changed_record_count + exact_duplicate_record_count + ].freeze + end + + def ensure_processed_with_count_statistics + if processed_at? && count_columns.any? { |column| send(column).nil? } + raise "Count statistics must be set for a processed import." + end + end + + private + def csv_is_valid errors.add(:csv, :invalid) unless csv_data_object.well_formed? end @@ -183,20 +199,6 @@ def csv_has_records errors.add(:csv, :empty) if csv_has_no_records end - def count_columns - %i[ - new_record_count - changed_record_count - exact_duplicate_record_count - ].freeze - end - - def ensure_processed_with_count_statistics - if processed_at? && count_columns.any? { |column| send(column).nil? } - raise "Count statistics must be set for a processed import." - end - end - def aggregate_row_level_errors row_offset = csv_data_object.has_instruction_row? ? 3 : 2 From 5b1cf619b6a72d2543f486575485c24ffaf4bb9d Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Mon, 27 Apr 2026 10:55:29 +0100 Subject: [PATCH 43/74] Make #csv method a one-liner Co-authored-by: Chris Roos Jira-Issue: MAV-7090 --- app/models/concerns/csv_importable.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 9c4431350f..7a8ce4f898 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -127,9 +127,7 @@ def csv=(source) end # Needed so that validations match the form field name. - def csv - csv_data - end + def csv = csv_data def csv_data_object @csv_data_object ||= Import::CSVData.new(csv_data) From 292bb7674b8dc377145ef4b5bf54ab6fcf234ba5 Mon Sep 17 00:00:00 2001 From: Chris Roos Date: Mon, 27 Apr 2026 16:49:02 +0100 Subject: [PATCH 44/74] Display helpful message if gias import file doesn't exist It took me a while to realise that I needed to run `mavis gias download` before I could run the `import`. Hopefully this error message makes it easier for the next person that tries the same thing. I've changed the behaviour of `capture_error` so that it returns both the error message and the instance of the `SystemExit` exception in the case where `exit` is called. This allows me to check for both the error message I've added and that the command exits with a non-zero code. As far as I can tell no other tests are relying on the return value from the `SystemExit` error handler so this appears to be a safe change to make. --- app/lib/mavis_cli/gias/import.rb | 5 +++++ spec/features/cli_gias_import_spec.rb | 24 ++++++++++++++++++++++++ spec/support/capture_output.rb | 4 ++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/app/lib/mavis_cli/gias/import.rb b/app/lib/mavis_cli/gias/import.rb index 5ada59ac9e..b7f871937f 100644 --- a/app/lib/mavis_cli/gias/import.rb +++ b/app/lib/mavis_cli/gias/import.rb @@ -11,6 +11,11 @@ class Import < Dry::CLI::Command desc: "GIAS database file to use" def call(input_file:, **) + unless File.exist?(input_file) + warn "Input file (#{input_file}) not found. Run `bin/mavis gias download` first." + exit 1 # rubocop:disable Rails/Exit + end + MavisCLI.load_rails logger = Logger.new($stdout) diff --git a/spec/features/cli_gias_import_spec.rb b/spec/features/cli_gias_import_spec.rb index 1d94d43c6c..f7729b240b 100644 --- a/spec/features/cli_gias_import_spec.rb +++ b/spec/features/cli_gias_import_spec.rb @@ -13,6 +13,11 @@ and_sites_are_updated_too end + it "displays a helpful message if the import file doesn't exist" do + when_i_run_the_import_command_with_a_non_existent_file + then_i_should_see_a_helpful_error_message + end + def given_a_gias_file_exists # Nothing to do here, it's a part of the fixtures end @@ -44,6 +49,25 @@ def when_i_run_the_import_command end end + def when_i_run_the_import_command_with_a_non_existent_file + @msg, @exit_status = + capture_error do + Dry::CLI.new(MavisCLI).call( + arguments: %w[gias import -i /non/existent/file] + ) + end + end + + def then_i_should_see_a_helpful_error_message + expect(@msg.chomp).to eq( + "Input file (/non/existent/file) not found. Run `bin/mavis gias download` first." + ) + end + + def and_the_exit_status_should_indicate_that_it_was_unsuccessful + expect(@exit_status.status).to eq(1) + end + def then_schools_are_imported_correctly expect(Location.count).to eq(7) expect(Location.find_by_urn_and_site("100000").name).to eq( diff --git a/spec/support/capture_output.rb b/spec/support/capture_output.rb index cda6829bd1..c43284c19b 100755 --- a/spec/support/capture_output.rb +++ b/spec/support/capture_output.rb @@ -38,8 +38,8 @@ def capture_error $stderr = error yield error.string - rescue SystemExit - error.string + rescue SystemExit => e + [error.string, e] ensure $stderr = original_stderr end From 4cd3ffaddfb4bafa58212c5e5af952328bc187df Mon Sep 17 00:00:00 2001 From: Steve Hook Date: Thu, 16 Apr 2026 11:09:41 +0100 Subject: [PATCH 45/74] Drop the outcome step from the already had flow The outcome should always be _vaccinated_ so there is no need to ask this question (and it may cause issues if the user changes the default answer) Jira-Issue: MAV-6544 --- app/models/draft_vaccination_record.rb | 2 +- spec/features/record_already_vaccinated_hpv_spec.rb | 6 ------ spec/features/record_already_vaccinated_mmr_spec.rb | 7 ------- spec/features/record_already_vaccinated_mmrv_spec.rb | 9 --------- spec/features/record_already_vaccinated_td_ipv_spec.rb | 6 ------ 5 files changed, 1 insertion(+), 29 deletions(-) diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 522f8be547..692300bd41 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -69,7 +69,7 @@ def wizard_steps end ), :date_and_time, - (:outcome if can_change_outcome?), + (:outcome if can_change_outcome? && !reported_as_already_vaccinated?), (:supplier if requires_supplied_by?), (:delivery if administered? && !reported_as_already_vaccinated?), ( diff --git a/spec/features/record_already_vaccinated_hpv_spec.rb b/spec/features/record_already_vaccinated_hpv_spec.rb index ac87295c8f..dc2dec6d15 100644 --- a/spec/features/record_already_vaccinated_hpv_spec.rb +++ b/spec/features/record_already_vaccinated_hpv_spec.rb @@ -13,7 +13,6 @@ when_i_click_record_already_vaccinated and_i_choose_a_date - and_i_choose_an_outcome then_i_see_the_confirmation_page when_i_confirm_the_details @@ -65,11 +64,6 @@ def and_i_choose_a_date click_on "Continue" end - def and_i_choose_an_outcome - # Vaccinated should already be selected - click_on "Continue" - end - def then_i_see_the_confirmation_page expect(page).to have_content("Check and confirm") expect(page).to have_content("OutcomeVaccinated") diff --git a/spec/features/record_already_vaccinated_mmr_spec.rb b/spec/features/record_already_vaccinated_mmr_spec.rb index 12d912aaf2..6c297bff23 100644 --- a/spec/features/record_already_vaccinated_mmr_spec.rb +++ b/spec/features/record_already_vaccinated_mmr_spec.rb @@ -10,7 +10,6 @@ when_i_click_record_first_dose_vaccinated and_i_choose_a_date_for_the_first_dose - and_i_choose_an_outcome then_i_see_the_confirmation_page_for_the_first_dose when_i_confirm_the_details @@ -22,7 +21,6 @@ when_i_click_record_second_dose_vaccinated and_i_choose_a_date_for_the_second_dose - and_i_choose_an_outcome then_i_see_the_confirmation_page_for_the_second_dose when_i_confirm_the_details @@ -91,11 +89,6 @@ def and_i_choose_a_date_for_the_second_dose click_on "Continue" end - def and_i_choose_an_outcome - # Vaccinated should already be selected - click_on "Continue" - end - def then_i_see_the_confirmation_page_for_the_first_dose expect(page).to have_content("Check and confirm") expect(page).to have_content("OutcomeVaccinated") diff --git a/spec/features/record_already_vaccinated_mmrv_spec.rb b/spec/features/record_already_vaccinated_mmrv_spec.rb index 0d21e640a3..3056fc22f1 100644 --- a/spec/features/record_already_vaccinated_mmrv_spec.rb +++ b/spec/features/record_already_vaccinated_mmrv_spec.rb @@ -11,7 +11,6 @@ when_i_click_record_first_dose_vaccinated and_i_choose_mmr_was_given and_i_choose_a_date_for_the_first_dose - and_i_choose_an_outcome then_i_see_the_confirmation_page_for_the_first_dose when_i_confirm_the_details @@ -24,7 +23,6 @@ when_i_click_record_second_dose_vaccinated and_i_choose_mmr_was_given and_i_choose_a_date_for_the_second_dose - and_i_choose_an_outcome then_i_see_the_confirmation_page_for_the_second_dose when_i_confirm_the_details @@ -45,7 +43,6 @@ when_i_click_record_first_dose_vaccinated and_i_choose_mmrv_was_given and_i_choose_a_date_for_the_first_dose - and_i_choose_an_outcome then_i_see_the_confirmation_page_for_the_first_dose when_i_confirm_the_details @@ -58,7 +55,6 @@ when_i_click_record_second_dose_vaccinated and_i_choose_mmrv_was_given and_i_choose_a_date_for_the_second_dose - and_i_choose_an_outcome then_i_see_the_confirmation_page_for_the_second_dose when_i_confirm_the_details @@ -137,11 +133,6 @@ def and_i_choose_a_date_for_the_second_dose click_on "Continue" end - def and_i_choose_an_outcome - # Vaccinated should already be selected - click_on "Continue" - end - def then_i_see_the_confirmation_page_for_the_first_dose expect(page).to have_content("Check and confirm") expect(page).to have_content("OutcomeVaccinated") diff --git a/spec/features/record_already_vaccinated_td_ipv_spec.rb b/spec/features/record_already_vaccinated_td_ipv_spec.rb index 749ddd09e7..00310df8a3 100644 --- a/spec/features/record_already_vaccinated_td_ipv_spec.rb +++ b/spec/features/record_already_vaccinated_td_ipv_spec.rb @@ -13,7 +13,6 @@ when_i_click_record_already_vaccinated and_i_choose_a_date - and_i_choose_an_outcome then_i_see_the_confirmation_page when_i_confirm_the_details @@ -74,11 +73,6 @@ def and_i_choose_a_date click_on "Continue" end - def and_i_choose_an_outcome - # Vaccinated should already be selected - click_on "Continue" - end - def then_i_see_the_confirmation_page expect(page).to have_content("Check and confirm") expect(page).to have_content("OutcomeVaccinated") From ffdcfe5dbfd97e5f892b3148c56c879fdb863f37 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 20 Apr 2026 11:00:19 +0100 Subject: [PATCH 46/74] Add `PatientDeleter` service class This class will make it easier to delete `Patient`s, deleting all of the associated objects, before deleting the `Patient`s themselves. It features a `confirm_production_delete` argument, which must be `true` if `Rails.env.production?`. This should help prevent any accidental deletion of patients in production. Jira-Issue: MAV-7067 --- app/lib/patient_deleter.rb | 60 ++++++ spec/lib/patient_deleter_spec.rb | 313 +++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 app/lib/patient_deleter.rb create mode 100644 spec/lib/patient_deleter_spec.rb diff --git a/app/lib/patient_deleter.rb b/app/lib/patient_deleter.rb new file mode 100644 index 0000000000..ecc914de1d --- /dev/null +++ b/app/lib/patient_deleter.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class PatientDeleter + class ProductionDeletionError < StandardError + def message + "PatientDeleter requires `confirm_production_delete: true` in production" + end + end + + def initialize(patients:, confirm_production_delete: false) + @patients = patients + @confirm_production_delete = confirm_production_delete + end + + def call + if Rails.env.production? && !@confirm_production_delete + raise ProductionDeletionError + end + + ActiveRecord::Base.transaction do + delete_related(ArchiveReason) + delete_related(AttendanceRecord) + delete_related(ClinicNotification) + delete_related(ConsentNotification) + delete_related(Consent) + delete_related(GillickAssessment) + delete_related(ImportantNotice) + delete_related(Note) + delete_related(PatientChangeset) + delete_related(PatientLocation) + delete_related(PatientSpecificDirection) + delete_related(PreScreening) + delete_related(SchoolMove) + delete_related(SessionNotification) + delete_related(Triage) + delete_related(VaccinationRecord.with_discarded) + + parent_ids = + Parent.joins(parent_relationships: :patient).merge(@patients).ids + delete_related(ParentRelationship) + Parent + .where(id: parent_ids) + .where + .missing(:parent_relationships) + .destroy_all + + @patients.each(&:destroy) + end + end + + def self.call(...) = new(...).call + + private_class_method :new + + private + + def delete_related(scope) + scope.joins(:patient).merge(@patients).delete_all + end +end diff --git a/spec/lib/patient_deleter_spec.rb b/spec/lib/patient_deleter_spec.rb new file mode 100644 index 0000000000..a4f1d871ad --- /dev/null +++ b/spec/lib/patient_deleter_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +describe PatientDeleter do + subject(:call) { described_class.call(patients:, confirm_production_delete:) } + + let(:confirm_production_delete) { false } + let(:programme) { Programme.hpv } + let(:team) { create(:team, programmes: [programme]) } + let(:session) { create(:session, team:, programmes: [programme]) } + let(:patients) { Patient.where(id: patient.id) } + let!(:patient) { create(:patient) } + + it "deletes the patient" do + expect { call }.to change(Patient, :count).by(-1) + expect { patient.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context "with multiple patients" do + let!(:other_patient) { create(:patient) } + let(:patients) { Patient.where(id: [patient.id, other_patient.id]) } + + it "deletes all patients" do + expect { call }.to change(Patient, :count).by(-2) + end + end + + context "in production" do + before { allow(Rails.env).to receive(:production?).and_return(true) } + + context "when confirm_production_delete is false" do + let(:confirm_production_delete) { false } + + it "raises an error" do + expect { call }.to raise_error(PatientDeleter::ProductionDeletionError) + end + + it "does not delete the patient" do + expect { call }.to raise_error(PatientDeleter::ProductionDeletionError) + expect { patient.reload }.not_to raise_error + end + end + + context "when confirm_production_delete is true" do + let(:confirm_production_delete) { true } + + it "deletes the patient" do + expect { call }.to change(Patient, :count).by(-1) + end + end + end + + context "with associated records" do + let(:archive_reason) do + create(:archive_reason, :moved_out_of_area, patient:, team:) + end + let(:attendance_record) { create(:attendance_record, :present, patient:) } + let(:patient_changeset) do + create(:patient_changeset, :class_import, patient:) + end + let(:clinic_notification) do + create(:clinic_notification, :initial_invitation, patient:, session:) + end + let(:consent_notification) do + create(:consent_notification, :request, patient:, session:) + end + let(:consent) { create(:consent, patient:, programme:) } + let(:gillick_assessment) do + create(:gillick_assessment, :competent, patient:) + end + let(:important_notice) { create(:important_notice, :deceased, patient:) } + let(:note) { create(:note, patient:, session:) } + let(:patient_programme_vaccinations_search) do + create(:patient_programme_vaccinations_search, patient:, programme:) + end + let(:patient_specific_direction) do + create(:patient_specific_direction, patient:, programme:) + end + let(:patient_location) { create(:patient_location, session:, patient:) } + let(:pre_screening) { create(:pre_screening, patient:) } + let(:school_move) { create(:school_move, :to_school, patient:) } + let(:session_notification) do + create(:session_notification, :school_reminder, patient:, session:) + end + let(:triage) { create(:triage, :safe_to_vaccinate, patient:, programme:) } + let(:vaccination_record) do + create(:vaccination_record, patient:, session:, programme:) + end + let(:discarded_vaccination_record) do + create(:vaccination_record, :discarded, patient:, session:, programme:) + end + + it "deletes archive reasons" do + archive_reason + expect { call }.to change(ArchiveReason, :count).by(-1) + end + + it "deletes attendance records" do + attendance_record + expect { call }.to change(AttendanceRecord, :count).by(-1) + end + + it "deletes patient changesets" do + patient_changeset + expect { call }.to change(PatientChangeset, :count).by(-1) + end + + it "deletes clinic notifications" do + clinic_notification + expect { call }.to change(ClinicNotification, :count).by(-1) + end + + it "deletes consent notifications" do + consent_notification + expect { call }.to change(ConsentNotification, :count).by(-1) + end + + it "deletes consents" do + consent + expect { call }.to change(Consent, :count).by(-1) + end + + it "deletes gillick assessments" do + gillick_assessment + expect { call }.to change(GillickAssessment, :count).by(-1) + end + + it "deletes important notices" do + important_notice + expect { call }.to change(ImportantNotice, :count).by(-1) + end + + it "deletes notes" do + note + expect { call }.to change(Note, :count).by(-1) + end + + it "deletes patient programme vaccinations searches" do + patient_programme_vaccinations_search + expect { call }.to change(PatientProgrammeVaccinationsSearch, :count).by( + -1 + ) + end + + it "deletes patient specific directions" do + patient_specific_direction + expect { call }.to change(PatientSpecificDirection, :count).by(-1) + end + + it "deletes patient locations" do + patient_location + expect { call }.to change(PatientLocation, :count).by(-1) + end + + it "deletes pre-screenings" do + pre_screening + expect { call }.to change(PreScreening, :count).by(-1) + end + + it "deletes school moves" do + school_move + expect { call }.to change(SchoolMove, :count).by(-1) + end + + it "deletes session notifications" do + session_notification + expect { call }.to change(SessionNotification, :count).by(-1) + end + + it "deletes triages" do + triage + expect { call }.to change(Triage, :count).by(-1) + end + + it "deletes vaccination records" do + vaccination_record + expect { call }.to change(VaccinationRecord.with_discarded, :count).by(-1) + end + + it "deletes discarded vaccination records" do + discarded_vaccination_record + expect { call }.to change(VaccinationRecord.with_discarded, :count).by(-1) + end + end + + context "with import associations" do + let(:class_import) { create(:class_import) } + let(:cohort_import) { create(:cohort_import) } + let(:immunisation_import) { create(:immunisation_import, team:) } + + before do + patient.class_imports << class_import + patient.cohort_imports << cohort_import + patient.immunisation_imports << immunisation_import + end + + it "does not delete class imports" do + expect { call }.not_to change(ClassImport, :count) + end + + it "does not delete cohort imports" do + expect { call }.not_to change(CohortImport, :count) + end + + it "does not delete immunisation imports" do + expect { call }.not_to change(ImmunisationImport, :count) + end + end + + context "with parent relationships" do + let(:parent_relationship) { create(:parent_relationship, patient:) } + let(:parent) { parent_relationship.parent } + + it "deletes the parent relationship" do + parent_relationship + expect { call }.to change(ParentRelationship, :count).by(-1) + end + + it "destroys orphaned parents" do + parent_relationship + expect { call }.to change(Parent, :count).by(-1) + expect { parent.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context "when the parent has another child" do + let!(:other_patient) { create(:patient) } + + before { create(:parent_relationship, parent:, patient: other_patient) } + + context "when only one child is deleted" do + it "removes only 1 parent relationship" do + expect { call }.to change(ParentRelationship, :count).by(-1) + end + + it "keeps the parent" do + expect { call }.not_to change(Parent, :count) + expect { parent.reload }.not_to raise_error + end + + it "does not remove the parent relationship with the other child" do + call + expect(other_patient.parents.reload).to contain_exactly(parent) + end + end + end + + context "when all the parent's children are being deleted" do + let!(:other_patient) { create(:patient) } + let(:patients) { Patient.where(id: [patient.id, other_patient.id]) } + + before { create(:parent_relationship, parent:, patient: other_patient) } + + it "deletes both parent relationships" do + parent_relationship + expect { call }.to change(ParentRelationship, :count).by(-2) + end + + it "destroys the parent" do + parent_relationship + expect { call }.to change(Parent, :count).by(-1) + expect { parent.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + # This test ensures that if a new table is added with a non-cascading FK + # to patients, and prompt the developer to handle it in PatientDeleter. + describe "non-cascading patient FK coverage" do + it "covers all non-cascading FK relationships to the patients table" do + non_cascading_fk_tables = + ActiveRecord::Base.connection.tables.flat_map do |table| + foreign_key = + ActiveRecord::Base + .connection + .foreign_keys(table) + .select do |fk| + fk.to_table == "patients" && fk.options[:on_delete] != :cascade + end + foreign_key.map(&:from_table) + end + non_cascading_fk_tables = non_cascading_fk_tables.to_set + + # Tables explicitly handled by PatientDeleter + explicitly_handled = %w[ + archive_reasons + attendance_records + clinic_notifications + consent_notifications + consents + gillick_assessments + important_notices + notes + parent_relationships + patient_changesets + patient_locations + patient_programme_vaccinations_searches + patient_specific_directions + pre_screenings + school_moves + session_notifications + triages + vaccination_records + ].to_set + + unhandled = non_cascading_fk_tables - explicitly_handled + + expect(unhandled).to( + be_empty, + "The following tables have non-cascading FKs to patients but are " \ + "not handled in PatientDeleter: #{unhandled.to_a.sort.join(", ")}" + ) + end + end +end From dfc1446f6b78c0490dffcbfd25755131a82e4376 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 20 Apr 2026 17:30:48 +0100 Subject: [PATCH 47/74] Refactor `reset_national_reporting` to use `PatientDeleter` Jira-Issue: MAV-7067 --- .../teams/reset_national_reporting.rb | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/app/lib/mavis_cli/teams/reset_national_reporting.rb b/app/lib/mavis_cli/teams/reset_national_reporting.rb index 288012c4b5..45d676ddc5 100644 --- a/app/lib/mavis_cli/teams/reset_national_reporting.rb +++ b/app/lib/mavis_cli/teams/reset_national_reporting.rb @@ -145,24 +145,17 @@ def reset_team(team) patients_to_destroy = find_patients_without_team(patient_ids_of_not_synced_records) - # We need to ensure we only update statuses for patients who are not - # destroyed. This should be the same as the list of patients _with_ - # a team, but it's fine to use this subtraction to be safe. + patient_ids_to_update += patient_ids_of_not_synced_records - patients_to_destroy.ids puts " - Found #{patients_to_destroy.count}" \ " patient(s) who were in the imports, and no longer have teams" - access_log_entries = - AccessLogEntry.where(patient_id: patients_to_destroy.ids) - puts " - Found #{access_log_entries.count} access_log_entries for" \ - " patients without teams" - - puts "Destroying access-log-entries..." - access_log_entries.destroy_all - - puts "Destroying patients..." - patients_to_destroy.destroy_all + puts "Destroying #{patients_to_destroy.count} patients..." + PatientDeleter.call( + patients: patients_to_destroy, + confirm_production_delete: true + ) end puts "Enqueueing jobs to update statuses for" \ From e3ee8f49746ba6aaa5514a09cdf7021fd10b8299 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 20 Apr 2026 17:33:23 +0100 Subject: [PATCH 48/74] Refactor test reset endpoint to use `PatientDeleter` Jira-Issue: MAV-7067 --- .../api/testing/teams_controller.rb | 45 +++---------------- .../api/testing/teams_controller_spec.rb | 6 ++- 2 files changed, 12 insertions(+), 39 deletions(-) diff --git a/app/controllers/api/testing/teams_controller.rb b/app/controllers/api/testing/teams_controller.rb index 2835154b59..4dcd1049c9 100644 --- a/app/controllers/api/testing/teams_controller.rb +++ b/app/controllers/api/testing/teams_controller.rb @@ -57,43 +57,10 @@ def destroy ) ) - patient_ids = team.patients.pluck(:id) - - log_destroy( - PatientLocation.joins_team_locations.where(team_locations: { team_id: }) - ) - - log_destroy(AccessLogEntry.where(patient_id: patient_ids)) - log_destroy(ArchiveReason.where(patient_id: patient_ids)) - log_destroy(AttendanceRecord.where(patient_id: patient_ids)) - log_destroy(ConsentNotification.where(patient_id: patient_ids)) - log_destroy(GillickAssessment.where(patient_id: patient_ids)) - log_destroy(Note.where(patient_id: patient_ids)) # In local dev we can end up with NotifyLogEntries without a patient log_destroy(NotifyLogEntry.where(patient_id: nil)) - log_destroy(NotifyLogEntry.where(patient_id: patient_ids)) - log_destroy(PatientChangeset.where(patient_id: patient_ids)) - log_destroy(PatientLocation.where(patient_id: patient_ids)) - log_destroy(PatientMergeLogEntry.where(patient_id: patient_ids)) - log_destroy(PatientSpecificDirection.where(patient_id: patient_ids)) - log_destroy(PDSSearchResult.where(patient_id: patient_ids)) - log_destroy(PreScreening.where(patient_id: patient_ids)) - log_destroy(SchoolMove.where(patient_id: patient_ids)) - log_destroy(SchoolMoveLogEntry.where(patient_id: patient_ids)) - log_destroy(VaccinationRecord.where(patient_id: patient_ids)) - log_destroy(Triage.where(patient_id: patient_ids)) - log_destroy(ImportantNotice.where(patient_id: patient_ids)) - - log_destroy(ParentRelationship.where(patient_id: patient_ids)) - log_destroy( - PatientProgrammeVaccinationsSearch.where(patient_id: patient_ids) - ) - log_destroy(Patient.where(id: patient_ids)) - log_destroy( - Consent.where(parent: Parent.where.missing(:parent_relationships)) - ) - log_destroy(Parent.where.missing(:parent_relationships)) + log_destroy_patients(patients: team.patients) log_destroy(Batch.where(team:)) @@ -103,10 +70,6 @@ def destroy log_destroy(Triage.where(team:)) - # These should have been deleted anyway due to the foreign key cascade, but - # just to be safe. - log_destroy(PatientTeam.where(team:)) - TeamCachedCounts.new(team).reset_all! log_destroy(Session.for_team(team)) @@ -186,4 +149,10 @@ def log_destroy(query) ) @log_time = Time.zone.now end + + def log_destroy_patients(patients:) + PatientDeleter.call(patients:) + response.stream.write("PatientDeleter.call(patients: team.patients)") + @log_time = Time.zone.now + end end diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb index a77629d0da..b63eefc21b 100644 --- a/spec/controllers/api/testing/teams_controller_spec.rb +++ b/spec/controllers/api/testing/teams_controller_spec.rb @@ -4,7 +4,11 @@ include ActiveJob::TestHelper include ImportsHelper - before { Flipper.enable(:testing_api) } + before do + Flipper.enable(:testing_api) + allow(Rails.env).to receive(:production?).and_return(false) + end + after { Flipper.disable(:testing_api) } around { |example| travel_to(Date.new(2025, 7, 31)) { example.run } } From 4b5f5f5b24eb21ad7954e3944f6fe39e86b77fcf Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 27 Apr 2026 14:43:04 +0100 Subject: [PATCH 49/74] Remove extra logging from `destroy_locations` It was decided that this level of logging was unnecessary after `PatientDeleter` was introduced, particularly because of the introduction of the unit test which checks which FK relationships are deleted by the `PatientDeleter` Jira-Issue: MAV-7067 --- .../api/testing/teams_controller.rb | 165 +++++++----------- 1 file changed, 62 insertions(+), 103 deletions(-) diff --git a/app/controllers/api/testing/teams_controller.rb b/app/controllers/api/testing/teams_controller.rb index 4dcd1049c9..8ae41a7af7 100644 --- a/app/controllers/api/testing/teams_controller.rb +++ b/app/controllers/api/testing/teams_controller.rb @@ -1,83 +1,70 @@ # frozen_string_literal: true class API::Testing::TeamsController < API::Testing::BaseController - include ActionController::Live - def destroy - response.headers["Content-Type"] = "text/event-stream" - response.headers["Cache-Control"] = "no-cache" - keep_itself = ActiveModel::Type::Boolean.new.cast(params[:keep_itself]) team = Team.find_by!(workgroup: params[:workgroup]) team_id = team.id - @start_time = Time.zone.now - - log_destroy(CohortImport.where(team:)) - log_destroy(ImmunisationImport.where(team:)) - log_destroy(ClassImport.where(team:)) - - log_destroy(Consent.where(team:)) - log_destroy(ArchiveReason.where(team:)) - log_destroy(ImportantNotice.where(team:)) - - log_destroy( - NotifyLogEntry.joins(:team_location).where(team_location: { team_id: }) - ) - log_destroy(ConsentForm.for_team(team)) - - log_destroy(ClinicNotification.where(team_id:)) - log_destroy( - ConsentNotification.joins(session: :team_location).where( - team_location: { - team_id: - } - ) - ) - log_destroy( - ConsentNotification.joins(:team_location).where( - team_location: { - team_id: - } - ) - ) - log_destroy( - SessionNotification.joins(session: :team_location).where( - team_location: { - team_id: - } - ) - ) - log_destroy( - VaccinationRecord.joins(session: :team_location).where( - team_locations: { - team_id: - } - ) - ) + CohortImport.where(team:).delete_all + ImmunisationImport.where(team:).delete_all + ClassImport.where(team:).delete_all + + Consent.where(team:).delete_all + ArchiveReason.where(team:).delete_all + ImportantNotice.where(team:).delete_all + + NotifyLogEntry + .joins(:team_location) + .where(team_location: { team_id: }) + .delete_all + + ConsentForm.for_team(team).delete_all + + ClinicNotification.where(team_id:).delete_all + + ConsentNotification + .joins(session: :team_location) + .where(team_location: { team_id: }) + .delete_all + + ConsentNotification + .joins(:team_location) + .where(team_location: { team_id: }) + .delete_all + + SessionNotification + .joins(session: :team_location) + .where(team_location: { team_id: }) + .delete_all + + VaccinationRecord + .joins(session: :team_location) + .where(team_locations: { team_id: }) + .delete_all # In local dev we can end up with NotifyLogEntries without a patient - log_destroy(NotifyLogEntry.where(patient_id: nil)) + NotifyLogEntry.where(patient_id: nil).delete_all - log_destroy_patients(patients: team.patients) + PatientDeleter.call(patients: team.patients) - log_destroy(Batch.where(team:)) + Batch.where(team:).delete_all - log_destroy( - VaccinationRecord.where(performed_ods_code: team.organisation.ods_code) - ) + VaccinationRecord.where( + performed_ods_code: team.organisation.ods_code + ).delete_all - log_destroy(Triage.where(team:)) + Triage.where(team:).delete_all TeamCachedCounts.new(team).reset_all! - log_destroy(Session.for_team(team)) + Session.for_team(team).delete_all unless keep_itself - log_destroy(TeamLocation.where(team:)) - log_destroy(Subteam.where(team:)) - log_destroy(Team.where(id: team.id)) + TeamLocation.where(team:).delete_all + Subteam.where(team:).delete_all + Team.where(id: team.id).delete_all end response.stream.write "Done" @@ -91,9 +78,6 @@ def destroy_locations keep_base_locations = ActiveModel::Type::Boolean.new.cast(params[:keep_base_locations]) - response.headers["Content-Type"] = "text/event-stream" - response.headers["Cache-Control"] = "no-cache" - team = Team.find_by!(workgroup: params[:workgroup]) location_ids = team.team_locations.pluck(:location_id) @@ -103,56 +87,31 @@ def destroy_locations location_ids_to_delete = locations.pluck(:id) - log_destroy(AttendanceRecord.where(location_id: location_ids_to_delete)) - log_destroy(ClassImport.where(location_id: location_ids_to_delete)) - log_destroy(GillickAssessment.where(location_id: location_ids_to_delete)) - log_destroy(PatientLocation.where(location_id: location_ids_to_delete)) - log_destroy(PreScreening.where(location_id: location_ids_to_delete)) + AttendanceRecord.where(location_id: location_ids_to_delete).delete_all + ClassImport.where(location_id: location_ids_to_delete).delete_all + GillickAssessment.where(location_id: location_ids_to_delete).delete_all + PatientLocation.where(location_id: location_ids_to_delete).delete_all + PreScreening.where(location_id: location_ids_to_delete).delete_all team_location_ids = TeamLocation.where(location_id: location_ids_to_delete).pluck(:id) - log_destroy(Session.where(team_location_id: team_location_ids)) - log_destroy(TeamLocation.where(location_id: location_ids_to_delete)) + Session.where(team_location_id: team_location_ids).delete_all + TeamLocation.where(location_id: location_ids_to_delete).delete_all - log_destroy(VaccinationRecord.where(location_id: location_ids_to_delete)) + VaccinationRecord.where(location_id: location_ids_to_delete).delete_all location_year_group_ids = Location::YearGroup.where(location_id: location_ids_to_delete).pluck(:id) - log_destroy( - Location::ProgrammeYearGroup.where( - location_year_group_id: location_year_group_ids - ) - ) - log_destroy(Location::YearGroup.where(location_id: location_ids_to_delete)) - log_destroy(locations) + + Location::ProgrammeYearGroup.where( + location_year_group_id: location_year_group_ids + ).delete_all + + Location::YearGroup.where(location_id: location_ids_to_delete).delete_all + locations.delete_all if keep_base_locations Location.where(id: location_ids, site: "A").update_all(site: nil) end - - response.stream.write "Done" - rescue StandardError => e - response.status = :internal_server_error - response.stream.write "Error: #{e.message}\n" - ensure - response.stream.close - end - - private - - def log_destroy(query) - where_clause = query.where_clause - @log_time ||= Time.zone.now - query.delete_all - response.stream.write( - "#{query.model.name}.where(#{where_clause.to_h}): #{Time.zone.now - @log_time}s\n" - ) - @log_time = Time.zone.now - end - - def log_destroy_patients(patients:) - PatientDeleter.call(patients:) - response.stream.write("PatientDeleter.call(patients: team.patients)") - @log_time = Time.zone.now end end From fe9c6412ba6f067fc1a30d37a94f3b215bbed722 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 22 Apr 2026 10:12:09 +0100 Subject: [PATCH 50/74] Default `Team` factory to create NR teams with a cutoff date This is a more accurate depiction of what teams will look like in prod Jira-Issue: MAV-7080 --- spec/factories/teams.rb | 1 + .../teams/set_national_reporting_cut_off_date_spec.rb | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 42833d9437..560c7faa94 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -68,6 +68,7 @@ privacy_notice_url { nil } privacy_policy_url { nil } + national_reporting_cut_off_date { Date.new(2025, 11, 1) } programmes { [Programme.flu, Programme.hpv] } end diff --git a/spec/lib/mavis_cli/teams/set_national_reporting_cut_off_date_spec.rb b/spec/lib/mavis_cli/teams/set_national_reporting_cut_off_date_spec.rb index 370f42f34c..0392ced641 100644 --- a/spec/lib/mavis_cli/teams/set_national_reporting_cut_off_date_spec.rb +++ b/spec/lib/mavis_cli/teams/set_national_reporting_cut_off_date_spec.rb @@ -13,7 +13,14 @@ let(:cut_off_date) { Date.new(2026, 2, 1) } - let(:team) { create(:team, :national_reporting, workgroup: "NR-001") } + let(:team) do + create( + :team, + :national_reporting, + workgroup: "NR-001", + national_reporting_cut_off_date: nil + ) + end it "sets the cut-off date" do expect { command }.to change { From 8e79a69c8f22748e94cc3ef5ae829893b6aeb89a Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 27 Apr 2026 17:49:05 +0100 Subject: [PATCH 51/74] Explicitly handle `ImportantNotice`s in `PatientMerger` This used to be handled using a `dependent: :destroy` relationship on the `Patient` model. However, after the introduction of `PatientDeleter` (which explicitly deletes) `ImportantNotice`s, this relationship should be removed, replaced by explicit handling in `PatientMerger` Jira-Issue: MAV-7067 --- app/lib/patient_merger.rb | 2 ++ spec/lib/patient_merger_spec.rb | 56 +++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/app/lib/patient_merger.rb b/app/lib/patient_merger.rb index c110f50683..ff9efd1e75 100644 --- a/app/lib/patient_merger.rb +++ b/app/lib/patient_merger.rb @@ -25,6 +25,8 @@ def call patient_to_destroy.archive_reasons.destroy_all + patient_to_destroy.important_notices.destroy_all + patient_to_destroy.attendance_records.find_each do |attendance_record| if patient_to_keep.attendance_records.exists?( location_id: attendance_record.location_id, diff --git a/spec/lib/patient_merger_spec.rb b/spec/lib/patient_merger_spec.rb index 3b0b5b4b46..b060cc4bf5 100644 --- a/spec/lib/patient_merger_spec.rb +++ b/spec/lib/patient_merger_spec.rb @@ -57,6 +57,9 @@ let(:gillick_assessment) do create(:gillick_assessment, :competent, patient: patient_to_destroy) end + let(:important_notice) do + create(:important_notice, :deceased, patient: patient_to_destroy, team:) + end let(:note) { create(:note, patient: patient_to_destroy) } let(:notify_log_entry) do create(:notify_log_entry, :email, patient: patient_to_destroy) @@ -168,6 +171,11 @@ ) end + it "deletes important notices" do + important_notice + expect { call }.to change(ImportantNotice, :count).by(-1) + end + it "moves notes" do expect { call }.to change { note.reload.patient }.to(patient_to_keep) end @@ -492,4 +500,52 @@ end end end + + # This test ensures that if a new table is added with a non-cascading FK + # to patients, it will prompt the developer to handle it in PatientMerger. + describe "non-cascading patient FK coverage" do + it "covers all non-cascading FK relationships to the patients table" do + non_cascading_fk_tables = + ActiveRecord::Base.connection.tables.flat_map do |table| + foreign_key = + ActiveRecord::Base + .connection + .foreign_keys(table) + .select do |fk| + fk.to_table == "patients" && fk.options[:on_delete] != :cascade + end + foreign_key.map(&:from_table) + end + non_cascading_fk_tables = non_cascading_fk_tables.to_set + + # Tables explicitly handled by PatientMerger + explicitly_handled = %w[ + archive_reasons + attendance_records + clinic_notifications + consent_notifications + consents + gillick_assessments + important_notices + notes + parent_relationships + patient_changesets + patient_locations + patient_specific_directions + pre_screenings + school_moves + session_notifications + triages + vaccination_records + ].to_set + + unhandled = non_cascading_fk_tables - explicitly_handled + + expect(unhandled).to( + be_empty, + "The following tables have non-cascading FKs to patients but are " \ + "not handled in PatientMerger: #{unhandled.to_a.sort.join(", ")}" + ) + end + end end From 65bdc4f7be3a878f33ba9f732906aaec618952c9 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 22 Apr 2026 09:45:25 +0100 Subject: [PATCH 52/74] Don't reset NR teams with no cutoff date This handles a potentially problematic edge case Jira-Issue: MAV-7080 --- .../teams/reset_national_reporting.rb | 8 ++++- ...cli_teams_reset_national_reporting_spec.rb | 34 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/lib/mavis_cli/teams/reset_national_reporting.rb b/app/lib/mavis_cli/teams/reset_national_reporting.rb index 45d676ddc5..9093990e7e 100644 --- a/app/lib/mavis_cli/teams/reset_national_reporting.rb +++ b/app/lib/mavis_cli/teams/reset_national_reporting.rb @@ -75,10 +75,16 @@ def find_teams(workgroup) raise ArgumentError, "Team #{workgroup} is not a national reporting team" end + if team.national_reporting_cut_off_date.nil? + raise ArgumentError, + "Team #{workgroup} does not have a national reporting cut off date set; no data to delete" + end [team] else - Team.where(type: :national_reporting) + Team + .where(type: :national_reporting) + .where.not(national_reporting_cut_off_date: nil) end end diff --git a/spec/features/cli_teams_reset_national_reporting_spec.rb b/spec/features/cli_teams_reset_national_reporting_spec.rb index 464967d670..858e6375b3 100644 --- a/spec/features/cli_teams_reset_national_reporting_spec.rb +++ b/spec/features/cli_teams_reset_national_reporting_spec.rb @@ -82,6 +82,16 @@ }.to raise_error(ArgumentError, /not a national reporting team/) end + it "raises an error when the team has no national reporting cut off date" do + given_a_national_reporting_team_exists_without_a_cut_off_date + expect { + run_command_with_workgroup(@national_reporting_team.workgroup) + }.to raise_error( + ArgumentError, + /does not have a national reporting cut off date set/ + ) + end + it "does not delete records that have been sent to the Imms API" do given_a_national_reporting_team_exists and_the_national_reporting_team_has_immunisation_imports_with_vaccination_records @@ -141,6 +151,18 @@ and_no_vaccination_records_are_deleted and_no_patients_are_deleted end + + it "skips national_reporting teams that have no cut off date set" do + given_a_national_reporting_team_exists_without_a_cut_off_date + and_the_national_reporting_team_has_immunisation_imports_with_vaccination_records + + when_i_run_the_command_for_all_teams + + then_the_output_indicates_no_teams_found + and_no_immunisation_imports_are_deleted + and_no_vaccination_records_are_deleted + and_no_patients_are_deleted + end end private @@ -160,6 +182,18 @@ def given_a_national_reporting_team_exists alias_method :and_a_national_reporting_team_exists, :given_a_national_reporting_team_exists + def given_a_national_reporting_team_exists_without_a_cut_off_date + @national_reporting_team = + create( + :team, + :national_reporting, + national_reporting_cut_off_date: nil, + programmes: [Programme.hpv, Programme.flu], + organisation: national_reporting_organisation, + workgroup: "national-reporting-team" + ) + end + def and_the_feature_flag_is_enabled Flipper.enable(:sync_national_reporting_to_imms_api) end From 3db02d395571871713ef76681f60abd2a2133c87 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 27 Apr 2026 16:23:17 +0100 Subject: [PATCH 53/74] Remove destroy hooks on `Patient` The `PatientDeleter` service class has been introduced as the designated mechanism for deleting patients. This class handles all the objects related to `Patient`, making these hooks redundant. Jira-Issue: MAV-7067 --- app/models/patient.rb | 15 +-------------- spec/models/patient_spec.rb | 30 ------------------------------ 2 files changed, 1 insertion(+), 44 deletions(-) diff --git a/app/models/patient.rb b/app/models/patient.rb index 2c6b86c282..735cba29ec 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -77,7 +77,7 @@ class Patient < ApplicationRecord has_many :consent_notifications has_many :consents has_many :gillick_assessments - has_many :important_notices, dependent: :destroy + has_many :important_notices has_many :notes has_many :notify_log_entries has_many :parent_relationships, -> { order(:created_at) } @@ -498,8 +498,6 @@ class Patient < ApplicationRecord after_update :sync_vaccinations_to_nhs_immunisations_api after_commit :generate_important_notice_if_needed, on: :update after_commit :search_vaccinations_from_nhs_immunisations_api, on: :update - before_destroy :destroy_childless_parents - delegate :fhir_record, to: :fhir_mapper def sessions @@ -824,17 +822,6 @@ def locations_are_correct_type end end - def destroy_childless_parents - parents_to_check = parents.to_a # Store parents before destroying relationships - - # Manually destroy the parent_relationships associated with this Child - parent_relationships.each(&:destroy) - - parents_to_check.each do |parent| - parent.destroy! if parent.parent_relationships.count.zero? - end - end - def archive_due_to_deceased! archive_reasons = teams.map do |team| diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 075ea562a0..b15503ae49 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -1446,36 +1446,6 @@ end end - describe "#destroy_childless_parents" do - context "when parent has only one child" do - let(:parent) { create(:parent) } - let!(:patient) { create(:patient, parents: [parent]) } - - it "destroys the parent when the patient is destroyed" do - expect { patient.destroy }.to change(Parent, :count).by(-1) - end - end - - context "when parent has multiple children" do - let(:parent) { create(:parent) } - let!(:patient) { create(:patient, parents: [parent]) } - - before { create(:patient, parents: [parent]) } - - it "does not destroy the parent when one patient is destroyed" do - expect { patient.destroy }.not_to change(Parent, :count) - end - end - - context "when patient has multiple parents" do - let!(:patient) { create(:patient, parents: create_list(:parent, 2)) } - - it "destroys only the childless parents" do - expect { patient.destroy }.to change(Parent, :count).by(-2) - end - end - end - describe "#latest_pds_search_result" do subject(:latest_pds_search_result) { patient.latest_pds_search_result } From 1ed7c051a35a896df985a3f5111b046c6bcd8874 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 22 Apr 2026 10:33:32 +0100 Subject: [PATCH 54/74] Only reset NR objects created before a team's cutoff date Because we weren't able to reset the piloting national reporting teams before they onboarded, we must now tweak this CLI tool to ensure that we don't affect information which the teams have uploaded since they onboarded for real, on their `national_reporting_cut_off_date`. Jira-Issue: MAV-7080 --- .../teams/reset_national_reporting.rb | 17 ++- ...cli_teams_reset_national_reporting_spec.rb | 130 +++++++++++++++++- 2 files changed, 138 insertions(+), 9 deletions(-) diff --git a/app/lib/mavis_cli/teams/reset_national_reporting.rb b/app/lib/mavis_cli/teams/reset_national_reporting.rb index 9093990e7e..0a17fab088 100644 --- a/app/lib/mavis_cli/teams/reset_national_reporting.rb +++ b/app/lib/mavis_cli/teams/reset_national_reporting.rb @@ -29,7 +29,8 @@ def call(workgroup: nil, force: false, **) puts "Found #{teams.count} national reporting team(s) to reset:" teams.each do |team| puts " - #{team.name} (#{team.workgroup})" - puts " - Immunisation imports: #{ImmunisationImport.where(team:).count}" + puts " - Cut-off date: #{team.national_reporting_cut_off_date}" + puts " - Immunisation imports (before cut-off): #{find_immunisation_imports_for_team(team).count}" puts " - Total patients: #{find_patients_for_team(team).count}" vaccination_records = find_vaccination_records_for_team(team) @@ -92,8 +93,9 @@ def reset_team(team) patient_ids_to_update = Set.new ActiveRecord::Base.transaction do - immunisation_imports = ImmunisationImport.where(team:) - puts " - Found #{immunisation_imports.count} immunisation import(s)" + immunisation_imports = find_immunisation_imports_for_team(team) + puts " - Found #{immunisation_imports.count} immunisation import(s) created before team's cut off date: " \ + "#{team.national_reporting_cut_off_date}" patient_ids = find_patients_for_team(team).ids puts " - Found #{patient_ids.count} patient(s) in this team" @@ -169,6 +171,13 @@ def reset_team(team) PatientStatusUpdaterJob.perform_bulk(patient_ids_to_update.zip) end + def find_immunisation_imports_for_team(team) + ImmunisationImport.where(team:).where( + "immunisation_imports.created_at < ?", + team.national_reporting_cut_off_date + ) + end + def find_patients_for_team(team) Patient.joins(:patient_teams).where(patient_teams: { team: }).distinct end @@ -176,7 +185,7 @@ def find_patients_for_team(team) def find_vaccination_records_for_team(team) VaccinationRecord.joins(:immunisation_imports).where( immunisation_imports: { - team: + id: find_immunisation_imports_for_team(team) } ) end diff --git a/spec/features/cli_teams_reset_national_reporting_spec.rb b/spec/features/cli_teams_reset_national_reporting_spec.rb index 858e6375b3..d4f037783e 100644 --- a/spec/features/cli_teams_reset_national_reporting_spec.rb +++ b/spec/features/cli_teams_reset_national_reporting_spec.rb @@ -28,6 +28,7 @@ given_a_national_reporting_team_exists and_i_upload_some_vaccination_records and_i_view_a_patient_record + and_the_cut_off_date_is_set_to_tomorrow when_i_run_the_command_for_single_team @@ -109,6 +110,18 @@ and_the_other_archive_reasons_are_deleted and_the_other_patients_are_deleted end + + it "only operates on imports created before the cut-off date" do + given_a_national_reporting_team_exists + and_the_national_reporting_team_has_immunisation_imports_both_before_and_after_the_cut_off_date + + when_i_run_the_command_for_single_team + + then_the_imports_before_the_cut_off_date_are_deleted + and_the_vaccination_records_from_imports_before_the_cut_off_date_are_deleted + and_the_imports_on_or_after_the_cut_off_date_are_not_deleted + and_the_vaccination_records_from_imports_on_or_after_the_cut_off_date_are_not_deleted + end end context "when resetting all national_reporting teams" do @@ -250,8 +263,22 @@ def and_i_view_the_patient_that_is_associated_with_both_teams end def and_the_national_reporting_team_has_immunisation_imports_with_vaccination_records - @import1 = create(:immunisation_import, team: @national_reporting_team) - @import2 = create(:immunisation_import, team: @national_reporting_team) + cut_off_date = + @national_reporting_team.national_reporting_cut_off_date || + Time.zone.today + + @import1 = + create( + :immunisation_import, + team: @national_reporting_team, + created_at: cut_off_date - 2.days + ) + @import2 = + create( + :immunisation_import, + team: @national_reporting_team, + created_at: cut_off_date - 1.day + ) @vaccination_record1 = create( @@ -279,7 +306,13 @@ def and_the_national_reporting_team_has_immunisation_imports_with_vaccination_re end def and_a_patient_is_associated_with_both_teams - @import = create(:immunisation_import, team: @national_reporting_team) + cut_off_date = @national_reporting_team.national_reporting_cut_off_date + @import = + create( + :immunisation_import, + team: @national_reporting_team, + created_at: cut_off_date - 1.day + ) @shared_patient = create(:patient) @national_reporting_vaccination_record = create( @@ -319,8 +352,19 @@ def given_multiple_national_reporting_teams_exist end def and_each_team_has_immunisation_imports - @import1 = create(:immunisation_import, team: @national_reporting_team1) - @import2 = create(:immunisation_import, team: @national_reporting_team2) + cut_off_date = @national_reporting_team1.national_reporting_cut_off_date + @import1 = + create( + :immunisation_import, + team: @national_reporting_team1, + created_at: cut_off_date - 1.day + ) + @import2 = + create( + :immunisation_import, + team: @national_reporting_team2, + created_at: cut_off_date - 1.day + ) @vaccination_record1 = create( @@ -536,7 +580,83 @@ def and_the_status_updater_job_is_enqueued ) end + def and_the_cut_off_date_is_set_to_tomorrow + @national_reporting_team.update!( + national_reporting_cut_off_date: Time.zone.tomorrow + ) + end + def and_the_access_log_entry_is_not_deleted expect(AccessLogEntry.where(patient: @shared_patient)).to exist end + + def and_the_national_reporting_team_has_immunisation_imports_both_before_and_after_the_cut_off_date + cut_off_date = @national_reporting_team.national_reporting_cut_off_date + + @import_before = + create( + :immunisation_import, + team: @national_reporting_team, + created_at: cut_off_date - 1.day + ) + @import_on_cut_off = + create( + :immunisation_import, + team: @national_reporting_team, + created_at: cut_off_date + ) + @import_after = + create( + :immunisation_import, + team: @national_reporting_team, + created_at: cut_off_date + 1.day + ) + + @vaccination_record_before = + create( + :vaccination_record, + :sourced_from_national_reporting, + immunisation_import: @import_before, + team: @national_reporting_team, + performed_at: cut_off_date - 2.days + ) + @vaccination_record_on_cut_off = + create( + :vaccination_record, + :sourced_from_national_reporting, + immunisation_import: @import_on_cut_off, + team: @national_reporting_team, + performed_at: cut_off_date + ) + @vaccination_record_after = + create( + :vaccination_record, + :sourced_from_national_reporting, + immunisation_import: @import_after, + team: @national_reporting_team, + performed_at: cut_off_date + 2.days + ) + end + + def then_the_imports_before_the_cut_off_date_are_deleted + expect(ImmunisationImport.where(id: @import_before.id)).to be_empty + end + + def and_the_vaccination_records_from_imports_before_the_cut_off_date_are_deleted + expect( + VaccinationRecord.where(id: @vaccination_record_before.id) + ).to be_empty + end + + def and_the_imports_on_or_after_the_cut_off_date_are_not_deleted + expect(ImmunisationImport.where(id: @import_on_cut_off.id)).to exist + expect(ImmunisationImport.where(id: @import_after.id)).to exist + end + + def and_the_vaccination_records_from_imports_on_or_after_the_cut_off_date_are_not_deleted + expect( + VaccinationRecord.where(id: @vaccination_record_on_cut_off.id) + ).to exist + expect(VaccinationRecord.where(id: @vaccination_record_after.id)).to exist + end end From 89ba0176c8ba9e66915b539c1acf5d6660c48f6a Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 11:03:26 +0100 Subject: [PATCH 55/74] Set `academic_year` in patient factory This ensures that when setting up a patient for a different academic year to the current one, any associated statuses are created for that academic year. Jira-Issue: MAV-7290 --- spec/factories/patients.rb | 63 +++++++++++++++++++++++++------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/spec/factories/patients.rb b/spec/factories/patients.rb index e4afa3cc49..e9e1908dc8 100644 --- a/spec/factories/patients.rb +++ b/spec/factories/patients.rb @@ -241,7 +241,8 @@ :patient_programme_status, :needs_consent_follow_up_requested, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -254,7 +255,8 @@ :patient_programme_status, :needs_consent_no_response, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -267,7 +269,8 @@ :patient_programme_status, :needs_consent_no_contact_details, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -292,7 +295,8 @@ :patient_programme_status, :due_injection, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -317,7 +321,8 @@ :patient_programme_status, :due_nasal, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -343,7 +348,8 @@ :patient_programme_status, :due_injection, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -368,7 +374,8 @@ :patient_programme_status, :due_nasal_injection, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -394,7 +401,8 @@ :patient_programme_status, :needs_triage, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -420,7 +428,8 @@ :patient_programme_status, :needs_triage, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -446,7 +455,8 @@ :patient_programme_status, :needs_triage, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -472,7 +482,8 @@ :patient_programme_status, :needs_triage, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -497,7 +508,8 @@ :patient_programme_status, :due_injection_without_gelatine, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -538,7 +550,8 @@ :patient_programme_status, :due_injection, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -564,7 +577,8 @@ :patient_programme_status, :due_nasal, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -601,7 +615,8 @@ :patient_programme_status, :due_nasal, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -638,7 +653,8 @@ :patient_programme_status, :due_injection_without_gelatine, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -663,7 +679,8 @@ :patient_programme_status, :has_refusal_consent_refused, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -715,7 +732,8 @@ :patient_programme_status, :has_refusal_consent_conflicts, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -772,7 +790,8 @@ :patient_programme_status, :cannot_vaccinate_do_not_vaccinate, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -818,7 +837,8 @@ :patient_programme_status, :cannot_vaccinate_delay_vaccination, patient: instance, - programme: + programme:, + academic_year: ) end end @@ -880,7 +900,8 @@ :patient_programme_status, :vaccinated_fully, patient: instance, - programme: + programme:, + academic_year: ) end end From 08bf14c90f0e841dbb051e25c3e020c18d8bc8f1 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 11:50:39 +0100 Subject: [PATCH 56/74] Fetch `team_location` as part of consent notifications This is needed to determine the academic year of the consent notification. Jira-Issue: MAV-7290 --- .../concerns/send_school_consent_notification_concern.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/jobs/concerns/send_school_consent_notification_concern.rb b/app/jobs/concerns/send_school_consent_notification_concern.rb index 091c6b388d..62eee8219e 100644 --- a/app/jobs/concerns/send_school_consent_notification_concern.rb +++ b/app/jobs/concerns/send_school_consent_notification_concern.rb @@ -8,7 +8,12 @@ def patient_programmes_eligible_for_notification(session:) session .patient_locations - .includes(patient: %i[consent_notifications programme_statuses]) + .includes( + patient: [ + :programme_statuses, + { consent_notifications: :team_location } + ] + ) .find_each do |patient_location| patient = patient_location.patient next unless patient.send_notifications?(team: session.team) From c4e5e2c2d3796e9e49a8318a5eef062887da42fd Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 11:52:24 +0100 Subject: [PATCH 57/74] Ensure consent requests are sent each academic year This fixes a bug where patients who already exist in Mavis and were sent a consent request from a previous academic year, won't be sent any consent requests for the current academic year. Jira-Issue: MAV-7290 --- app/jobs/send_school_consent_requests_job.rb | 7 +- .../send_school_consent_requests_job_spec.rb | 64 +++++++++++++++---- 2 files changed, 54 insertions(+), 17 deletions(-) diff --git a/app/jobs/send_school_consent_requests_job.rb b/app/jobs/send_school_consent_requests_job.rb index d808b86fd4..7cea02357d 100644 --- a/app/jobs/send_school_consent_requests_job.rb +++ b/app/jobs/send_school_consent_requests_job.rb @@ -15,16 +15,17 @@ def patients_and_programmes(session) patient_programmes_eligible_for_notification( session: ) do |patient, programmes| - if should_send_notification?(patient:, programmes:) + if should_send_notification?(patient:, session:, programmes:) yield patient, programmes end end end - def should_send_notification?(patient:, programmes:) + def should_send_notification?(patient:, session:, programmes:) programmes.any? do |programme| patient.consent_notifications.none? do - it.request? && it.programmes.include?(programme) + it.academic_year == session.academic_year && it.request? && + it.programmes.include?(programme) end end end diff --git a/spec/jobs/send_school_consent_requests_job_spec.rb b/spec/jobs/send_school_consent_requests_job_spec.rb index f1e99e0254..35cd79a64e 100644 --- a/spec/jobs/send_school_consent_requests_job_spec.rb +++ b/spec/jobs/send_school_consent_requests_job_spec.rb @@ -9,6 +9,33 @@ let(:patient_with_request_sent) do create(:patient, :consent_no_response, :consent_request_sent, programmes:) end + let(:patient_with_request_sent_last_year) do + previous_session = + create( + :session, + :unscheduled, + programmes:, + academic_year: AcademicYear.previous + ) + create( + :patient, + :consent_no_response, + :consent_request_sent, + year_group: 8, + parents:, + programmes:, + session: previous_session + ).tap do |patient| + programmes.each do |programme| + create( + :patient_programme_status, + :needs_consent_no_response, + patient:, + programme: + ) + end + end + end let(:patient_not_sent_request) do create(:patient, :consent_no_response, parents:, programmes:) end @@ -21,6 +48,7 @@ let!(:patients) do [ patient_with_request_sent, + patient_with_request_sent_last_year, patient_not_sent_request, patient_with_consent, deceased_patient, @@ -53,10 +81,13 @@ ) end - it "sends notifications to one patient" do - expect { perform_now }.to change(ConsentNotification, :count).by(1) - expect(ConsentNotification.last.patient_id).to eq( - patient_not_sent_request.id + it "sends notifications to expected patients" do + expect { perform_now }.to change(ConsentNotification, :count).by(2) + expect( + ConsentNotification.order(:sent_at).last(2).map(&:patient_id) + ).to contain_exactly( + patient_not_sent_request.id, + patient_with_request_sent_last_year.id ) end @@ -64,9 +95,12 @@ let(:programmes) { [Programme.menacwy, Programme.td_ipv] } it "sends one notification to one patient" do - expect { perform_now }.to change(ConsentNotification, :count).by(1) - expect(ConsentNotification.last.patient_id).to eq( - patient_not_sent_request.id + expect { perform_now }.to change(ConsentNotification, :count).by(2) + expect( + ConsentNotification.order(:sent_at).last(2).map(&:patient_id) + ).to contain_exactly( + patient_not_sent_request.id, + patient_with_request_sent_last_year.id ) end end @@ -85,11 +119,13 @@ before { PatientStatusUpdater.call(patient: patient_not_sent_request) } - it "sends only one notification for HPV" do - expect { perform_now }.to change(ConsentNotification, :count).by(1) - expect(ConsentNotification.last.programmes).to contain_exactly( - hpv_programme - ) + it "sends only notifications for HPV" do + expect { perform_now }.to change(ConsentNotification, :count).by(3) + expect( + ConsentNotification.find_by!( + patient: patient_not_sent_request + ).programmes + ).to contain_exactly(hpv_programme) end end @@ -100,8 +136,8 @@ before { PatientStatusUpdater.call(patient: patient_not_sent_request) } - it "sends two notifications for HPV, and MenACWY and Td/IPV" do - expect { perform_now }.to change(ConsentNotification, :count).by(2) + it "sends notifications for HPV, and MenACWY and Td/IPV separately" do + expect { perform_now }.to change(ConsentNotification, :count).by(4) expect( ConsentNotification.where(patient: patient_not_sent_request).map( &:programme_types From 979ce0a2dcf2c5ca3623010449e897f2ec6b4db3 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 12:27:00 +0100 Subject: [PATCH 58/74] Ensure consent reminders are sent each academic year This fixes a bug where patients who already exist in Mavis and were sent a consent reminder from a previous academic year, won't be sent any consent reminders for the current academic year. Jira-Issue: MAV-7290 --- ..._automatic_school_consent_reminders_job.rb | 40 +++++++++----- app/lib/notifier/patient.rb | 1 + ...matic_school_consent_reminders_job_spec.rb | 53 +++++++++++++++++++ spec/lib/notifier/patient_spec.rb | 35 +++++++++++- 4 files changed, 116 insertions(+), 13 deletions(-) diff --git a/app/jobs/send_automatic_school_consent_reminders_job.rb b/app/jobs/send_automatic_school_consent_reminders_job.rb index 03a5a89b81..7c57b7a5e2 100644 --- a/app/jobs/send_automatic_school_consent_reminders_job.rb +++ b/app/jobs/send_automatic_school_consent_reminders_job.rb @@ -17,7 +17,7 @@ def perform(session) def should_send_notification?(patient:, session:, programmes:) programmes.any? do |programme| - initial_request = initial_request(patient:, programme:) + initial_request = initial_request(patient:, session:, programme:) return false if initial_request.nil? date_to_send_reminder = @@ -33,11 +33,14 @@ def should_send_notification?(patient:, session:, programmes:) end end - def initial_request(patient:, programme:) + def initial_request(patient:, session:, programme:) patient .consent_notifications .sort_by(&:sent_at) - .find { it.request? && it.programmes.include?(programme) } + .find do + it.academic_year == session.academic_year && it.request? && + it.programmes.include?(programme) + end end def earliest_date_to_send_reminder( @@ -54,13 +57,21 @@ def earliest_date_to_send_reminder( it - session.days_before_consent_reminders.days end - date_index_to_send_reminder_for = - already_sent_automatic_consent_reminders_count(patient:, programme:) + - manual_consent_reminders_replacing_automatic_count( - patient:, - programme:, - scheduled_automatic_reminder_dates: - ) + automatic_count = + already_sent_automatic_consent_reminders_count( + patient:, + session:, + programme: + ) + + manual_count = + manual_consent_reminders_replacing_automatic_count( + patient:, + programme:, + scheduled_automatic_reminder_dates: + ) + + date_index_to_send_reminder_for = automatic_count + manual_count if date_index_to_send_reminder_for >= scheduled_automatic_reminder_dates.length @@ -70,9 +81,14 @@ def earliest_date_to_send_reminder( scheduled_automatic_reminder_dates[date_index_to_send_reminder_for] end - def already_sent_automatic_consent_reminders_count(patient:, programme:) + def already_sent_automatic_consent_reminders_count( + patient:, + session:, + programme: + ) patient.consent_notifications.count do - it.automated_reminder? && it.programmes.include?(programme) + it.academic_year == session.academic_year && it.automated_reminder? && + it.programmes.include?(programme) end end diff --git a/app/lib/notifier/patient.rb b/app/lib/notifier/patient.rb index 29d1d51930..0da844685f 100644 --- a/app/lib/notifier/patient.rb +++ b/app/lib/notifier/patient.rb @@ -49,6 +49,7 @@ def send_consent_reminder(programmes, session:, sent_by:) programmes.all? do |programme| patient .consent_notifications + .select { it.academic_year == session.academic_year } .select { it.programmes.include?(programme) } .any?(&:initial_reminder?) end diff --git a/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb b/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb index ba3254771b..3346a1ff43 100644 --- a/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb +++ b/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb @@ -130,6 +130,59 @@ ) end + context "when the patient's initial reminder was last year" do + let(:patient_with_initial_reminder) do + previous_session = + create( + :session, + :unscheduled, + programmes:, + academic_year: AcademicYear.previous + ) + create( + :patient, + :consent_request_sent, + :initial_consent_reminder_sent, + :consent_no_response, + year_group: 8, + parents:, + programmes:, + session: previous_session + ).tap do |patient| + create( + :consent_notification, + :request, + patient:, + session:, + programmes:, + sent_at: 1.week.ago + ) + + programmes.each do |programme| + create( + :patient_programme_status, + :needs_consent_no_response, + patient:, + programme: + ) + end + end + end + + it "sends notifications to one patient" do + expect { perform_now }.to change(ConsentNotification, :count).by(1) + + consent_notification = ConsentNotification.last + expect(consent_notification).to be_initial_reminder + expect(consent_notification.patient_id).to eq( + patient_not_sent_reminder.id + ) + expect(consent_notification.programmes).to contain_exactly( + programmes.first + ) + end + end + context "when location is a generic clinic" do let(:location) { create(:generic_clinic, team:) } diff --git a/spec/lib/notifier/patient_spec.rb b/spec/lib/notifier/patient_spec.rb index 06290f1252..35edb9cf47 100644 --- a/spec/lib/notifier/patient_spec.rb +++ b/spec/lib/notifier/patient_spec.rb @@ -606,6 +606,8 @@ end end + before { patient.strict_loading!(false) } + let(:today) { Date.new(2024, 1, 1) } let(:parents) { create_list(:parent, 2) } @@ -725,6 +727,10 @@ context "for the MMR(V) programme" do let(:programmes) { [Programme.mmr] } + before do + allow(patient).to receive(:eligible_for_mmrv?).and_return(false) + end + it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( :consent_school_reminder_mmr @@ -755,7 +761,7 @@ end end - context "and a patient that is eligible for mmrv" do + context "and a patient that is eligible for MMRV" do before do allow(patient).to receive(:eligible_for_mmrv?).and_return(true) end @@ -816,6 +822,33 @@ end end + context "when the patient has already got an initial reminder from a previous academic year" do + before do + session = + create(:session, programmes:, academic_year: AcademicYear.previous) + create( + :consent_notification, + :initial_reminder, + patient:, + programmes:, + session: + ) + end + + it "creates a record" do + expect { send_consent_reminder }.to change( + ConsentNotification, + :count + ).by(1) + + consent_notification = ConsentNotification.last + expect(consent_notification).to be_an_initial_reminder + expect(consent_notification.programmes).to eq(programmes) + expect(consent_notification.patient).to eq(patient) + expect(consent_notification.sent_at).to eq(today) + end + end + context "when the patient has already got an initial reminder" do before do create(:consent_notification, :initial_reminder, patient:, programmes:) From 9911f10aaff3d0678b62798b758d2740eb6246c0 Mon Sep 17 00:00:00 2001 From: CodeCorvin Date: Wed, 15 Apr 2026 10:45:50 +0100 Subject: [PATCH 59/74] Fix for parent_relationships_controller.rb and patient_programme_statuses.rb --- .../parent_relationships_controller.rb | 3 + spec/factories/patient_programme_statuses.rb | 5 + spec/features/parent_relationships_spec.rb | 95 ++++++++++++++++++- 3 files changed, 98 insertions(+), 5 deletions(-) diff --git a/app/controllers/parent_relationships_controller.rb b/app/controllers/parent_relationships_controller.rb index 620e899553..d8b06bb120 100644 --- a/app/controllers/parent_relationships_controller.rb +++ b/app/controllers/parent_relationships_controller.rb @@ -15,6 +15,7 @@ def create @patient.parent_relationships.build(parent_relationship_params) if @parent_relationship.save + PatientStatusUpdater.call(patient: @patient) redirect_to edit_patient_path(@patient) else render :new, status: :unprocessable_entity @@ -27,6 +28,7 @@ def edit def update if @parent_relationship.update(parent_relationship_params) + PatientStatusUpdater.call(patient: @patient) redirect_to edit_patient_path(@patient) else render :edit, status: :unprocessable_content @@ -38,6 +40,7 @@ def confirm_destroy = render :destroy def destroy @parent_relationship.destroy! + PatientStatusUpdater.call(patient: @patient) redirect_to edit_patient_path(@patient), flash: { success: "Parent relationship removed" diff --git a/spec/factories/patient_programme_statuses.rb b/spec/factories/patient_programme_statuses.rb index 9be52c7a9b..e04418675a 100644 --- a/spec/factories/patient_programme_statuses.rb +++ b/spec/factories/patient_programme_statuses.rb @@ -58,6 +58,11 @@ status { "needs_consent_follow_up_requested" } end + trait :needs_consent_no_contact_details do + consent_status { "no_contact_details" } + status { "needs_consent_no_contact_details" } + end + trait :needs_triage do consent_status { "given" } consent_vaccine_methods { %w[injection] } diff --git a/spec/features/parent_relationships_spec.rb b/spec/features/parent_relationships_spec.rb index 298363bc31..0505c6543d 100644 --- a/spec/features/parent_relationships_spec.rb +++ b/spec/features/parent_relationships_spec.rb @@ -1,9 +1,11 @@ # frozen_string_literal: true describe "Parent relationships" do - before { given_a_patient_with_a_parent_exists } + before { given_a_patient_exists } scenario "User removes a parent relationship from a patient" do + and_the_patient_has_a_parent + and_status_is_needs_consent_follow_up_requested when_i_visit_the_patient_page and_i_click_on_edit_child_record and_i_click_on_remove_parent @@ -16,21 +18,56 @@ and_i_delete_the_parent_relationship then_i_see_the_edit_child_record_page and_i_see_a_deletion_confirmation_message + and_status_becomes_no_contact_details end - def given_a_patient_with_a_parent_exists - programmes = [Programme.sample] - team = create(:team, programmes:) + scenario "User adds a parent relationship to a patient" do + and_status_is_no_contact_details + when_i_visit_the_patient_page + and_i_click_on_edit_child_record + and_i_click_on_add_parent_or_guardian + then_i_see_the_add_parent_or_guardian_page + + and_i_fill_in_form_for_new_parent + then_i_see_the_edit_child_record_page + + when_i_click_on_continue_to_confirm_changes + then_status_is_no_longer_no_contact_details + end + + def given_a_patient_exists + @programmes = [Programme.sample] + team = create(:team, programmes: @programmes) @nurse = create(:nurse, team:) - session = create(:session, team:, programmes:) + session = create(:session, team:, programmes: @programmes) @patient = create(:patient, session:) + end + def and_the_patient_has_a_parent @parent = create(:parent) create(:parent_relationship, patient: @patient, parent: @parent) end + def and_status_is_needs_consent_follow_up_requested + create( + :patient_programme_status, + :needs_consent_follow_up_requested, + patient: @patient, + programme: @programmes.sole + ) + end + + def and_status_is_no_contact_details + create( + :patient_programme_status, + :needs_consent_no_contact_details, + patient: @patient, + programme: @programmes.sole + ) + end + def when_i_visit_the_patient_page sign_in @nurse visit patient_path(@patient) @@ -44,6 +81,10 @@ def and_i_click_on_remove_parent click_on "Remove first parent or guardian" end + def and_i_click_on_add_parent_or_guardian + click_on "Add parent or guardian" + end + alias_method :when_i_click_on_remove_parent, :and_i_click_on_remove_parent def then_i_see_the_delete_parent_relationship_page @@ -52,6 +93,10 @@ def then_i_see_the_delete_parent_relationship_page ) end + def then_i_see_the_add_parent_or_guardian_page + expect(page).to have_content("Add parent or guardian") + end + def when_i_go_back_to_the_patient click_on "No, return to child record" end @@ -67,4 +112,44 @@ def and_i_delete_the_parent_relationship def and_i_see_a_deletion_confirmation_message expect(page).to have_content("Parent relationship removed") end + + def and_status_becomes_no_contact_details + expect( + @patient + .programme_statuses + .where(programme_type: @programmes.sole) + .first + .consent_status + .to_s + ).to eq("no_contact_details") + end + + def and_i_fill_in_form_for_new_parent + fill_in "Name", with: "John Doe" + + within("fieldset", text: "Relationship to child") { choose "Dad" } + + fill_in "Email address", with: "john.doe@info.info" + + within("fieldset", text: "Does the parent have any specific needs?") do + choose "They do not have specific needs" + end + + click_on "Save" + end + + def when_i_click_on_continue_to_confirm_changes + click_on "Continue" + end + + def then_status_is_no_longer_no_contact_details + expect( + @patient + .programme_statuses + .where(programme_type: @programmes.sole) + .first + .consent_status + .to_s + ).not_to eq("no_contact_details") + end end From 5b6d74ce7a4c6dc859ba33cd38ffeb7af780a8ae Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 14:49:06 +0100 Subject: [PATCH 60/74] Store `hint[:exception]` in to a variable This avoids the need to fetch the key each time and should make the code slightly more readable. --- config/initializers/sentry.rb | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index abf52902cc..f568e4a98a 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -60,31 +60,33 @@ config.before_send = lambda do |event, hint| + exception = hint[:exception] + # We don't want to send these errors to Sentry as they are too noisy. We # don't want to not raise them, however, since they trigger a retry with # Sidekiq, which we want. It can also be handy to have them in Splunk and # Cloudwatch to help with debugging. - next if hint[:exception].is_a?(Faraday::TooManyRequestsError) - if hint[:exception].is_a?(Faraday::ServerError) && - hint[:exception].message.include?( + next if exception.is_a?(Faraday::TooManyRequestsError) + + if exception.is_a?(Faraday::ServerError) && + exception.message.include?( "https://api.service.nhs.uk/immunisation-fhir-api" - ) && hint[:exception].message.include?("502") + ) && exception.message.include?("502") next end + # We don't want these errors in Sentry in non-production environments as + # they're not actionable. unless Rails.env.production? team_only_api_key_error = - hint[:exception].is_a?(Notifications::Client::BadRequestError) && - hint[:exception].message.include?( + exception.is_a?(Notifications::Client::BadRequestError) && + exception.message.include?( NotifyDeliveryJob::TEAM_ONLY_API_KEY_MESSAGE ) next if team_only_api_key_error - too_many_requests_error = - hint[:exception].is_a?(Faraday::TooManyRequestsError) - - next if too_many_requests_error + next if exception.is_a?(Faraday::TooManyRequestsError) end event.extra = combined_filter.filter(event.extra) if event.extra From 3b073a0e40e9fc8dc2760aca76bcf79e02237588 Mon Sep 17 00:00:00 2001 From: Mike Thompson Date: Wed, 29 Apr 2026 17:47:13 +0100 Subject: [PATCH 61/74] Add missing space in safe to vaccinate message for flu --- config/locales/status.en.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/locales/status.en.yml b/config/locales/status.en.yml index 70a3f4481d..76fcdee07b 100644 --- a/config/locales/status.en.yml +++ b/config/locales/status.en.yml @@ -138,7 +138,7 @@ en: safe_to_vaccinate_injection: Safe to vaccinate safe_to_vaccinate_nasal: Safe to vaccinate with nasal spray safe_to_vaccinate_injection_without_gelatine: Safe to vaccinate with gelatine-free injection - safe_to_vaccinate_injection_without_gelatine_flu: Safe to vaccinate withinjection + safe_to_vaccinate_injection_without_gelatine_flu: Safe to vaccinate with injection colour: delay_vaccination: orange invite_to_clinic: orange From ce6fcddd6d2873fd50884477204e65f685abb478 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 29 Apr 2026 18:48:32 +0100 Subject: [PATCH 62/74] Remove `--force` flag on national reporting reset CLI tool This flag was being used to check whether the feature flag was enabled to sync these records to the Imms API. This CLI tool has since been rewritten so it can safely handle being run after the teams have onboarded, making this check redundant. --- .../mavis_cli/teams/reset_national_reporting.rb | 14 +++----------- .../cli_teams_reset_national_reporting_spec.rb | 15 --------------- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/app/lib/mavis_cli/teams/reset_national_reporting.rb b/app/lib/mavis_cli/teams/reset_national_reporting.rb index 0a17fab088..b27cd3239c 100644 --- a/app/lib/mavis_cli/teams/reset_national_reporting.rb +++ b/app/lib/mavis_cli/teams/reset_national_reporting.rb @@ -7,18 +7,10 @@ class ResetNationalReporting < Dry::CLI::Command option :workgroup, desc: "The workgroup of a specific team to reset (optional)" - option :force, - desc: - "Ignore check if sync_national_reporting_to_imms_api feature is enabled" - def call(workgroup: nil, force: false, **) + def call(workgroup: nil, **) MavisCLI.load_rails - if !force && Flipper.enabled?(:sync_national_reporting_to_imms_api) - puts "Error: This operation is not allowed while sync_national_reporting_to_imms_api is enabled." - return - end - teams = find_teams(workgroup) if teams.empty? @@ -117,7 +109,7 @@ def reset_team(team) " record(s) will be deleted" end - puts "Destroying vaccination records..." + puts "Destroying #{not_synced_vaccination_records.count} vaccination records..." not_synced_vaccination_records.destroy_all puts "Refreshing immunisations imports..." @@ -135,7 +127,7 @@ def reset_team(team) " be deleted" end - puts "Destroying immunisation imports..." + puts "Destroying #{immunisation_imports.count} immunisation imports..." immunisation_imports.destroy_all archive_reasons = diff --git a/spec/features/cli_teams_reset_national_reporting_spec.rb b/spec/features/cli_teams_reset_national_reporting_spec.rb index d4f037783e..9604b0a2bc 100644 --- a/spec/features/cli_teams_reset_national_reporting_spec.rb +++ b/spec/features/cli_teams_reset_national_reporting_spec.rb @@ -8,21 +8,6 @@ end let(:point_of_care_organisation) { create(:organisation) } - context "when sync_national_reporting_to_imms_api feature flag is enabled" do - it "does not allow resetting national_reporting teams" do - given_a_national_reporting_team_exists - and_the_feature_flag_is_enabled - and_the_national_reporting_team_has_immunisation_imports_with_vaccination_records - - when_i_run_the_command_for_single_team - - then_an_error_is_displayed_in_output - and_no_immunisation_imports_are_deleted - and_no_vaccination_records_are_deleted - and_no_patients_are_deleted - end - end - context "when resetting a single national_reporting team" do it "removes all immunisation imports, associated vaccination records, and associated patients" do given_a_national_reporting_team_exists From cab3d06e83ac44c3dcc986b9e5f7dc8adf71ce5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:53:49 +0000 Subject: [PATCH 63/74] Bump aws-sdk-s3 from 1.219.0 to 1.220.0 Bumps [aws-sdk-s3](https://github.com/aws/aws-sdk-ruby) from 1.219.0 to 1.220.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.220.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 5b422221a1..fe5cfc67ea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,7 +178,7 @@ GEM protocol-websocket (~> 0.17) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1241.0) + aws-partitions (1.1242.0) aws-sdk-accessanalyzer (1.88.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) @@ -202,13 +202,13 @@ GEM aws-sdk-iam (1.142.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-kms (1.123.0) + aws-sdk-kms (1.124.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) aws-sdk-rds (1.311.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.219.0) + aws-sdk-s3 (1.220.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) From 2959e42b3e0eed8e4fba7d898db0ed2807cf63e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:53:53 +0000 Subject: [PATCH 64/74] Bump aws-actions/amazon-ecr-login from 2.1.3 to 2.1.4 Bumps [aws-actions/amazon-ecr-login](https://github.com/aws-actions/amazon-ecr-login) from 2.1.3 to 2.1.4. - [Release notes](https://github.com/aws-actions/amazon-ecr-login/releases) - [Changelog](https://github.com/aws-actions/amazon-ecr-login/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/amazon-ecr-login/compare/376925c9d111252e87ae59691e5a442dd100ef6a...19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7) --- updated-dependencies: - dependency-name: aws-actions/amazon-ecr-login dependency-version: 2.1.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build-and-push-database-image.yml | 2 +- .github/workflows/build-and-push-image.yml | 2 +- .github/workflows/end-to-end-tests-aws.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-push-database-image.yml b/.github/workflows/build-and-push-database-image.yml index c66bd1b66f..df1f4efc33 100644 --- a/.github/workflows/build-and-push-database-image.yml +++ b/.github/workflows/build-and-push-database-image.yml @@ -72,7 +72,7 @@ jobs: aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 + uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2.1.4 # yamllint disable rule:line-length - name: Get image tag id: get-image-tag diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index 71a94d524e..34dd036e44 100644 --- a/.github/workflows/build-and-push-image.yml +++ b/.github/workflows/build-and-push-image.yml @@ -114,7 +114,7 @@ jobs: aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 + uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2.1.4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # yamllint disable rule:line-length diff --git a/.github/workflows/end-to-end-tests-aws.yml b/.github/workflows/end-to-end-tests-aws.yml index 4cfda7d7f4..36fca9f4aa 100644 --- a/.github/workflows/end-to-end-tests-aws.yml +++ b/.github/workflows/end-to-end-tests-aws.yml @@ -70,7 +70,7 @@ jobs: aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 + uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2.1.4 - name: Build and push mavis/development docker image # yamllint disable rule:line-length run: | From 2224113cd0482af61605da46b31392291289874b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 21:54:26 +0000 Subject: [PATCH 65/74] Bump aws-sdk-ec2 from 1.612.0 to 1.613.0 Bumps [aws-sdk-ec2](https://github.com/aws/aws-sdk-ruby) from 1.612.0 to 1.613.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.613.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 5b422221a1..8a31607d0f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -178,7 +178,7 @@ GEM protocol-websocket (~> 0.17) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1241.0) + aws-partitions (1.1242.0) aws-sdk-accessanalyzer (1.88.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) @@ -193,7 +193,7 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-ec2 (1.612.0) + aws-sdk-ec2 (1.613.0) aws-sdk-core (~> 3, >= 3.244.0) aws-sigv4 (~> 1.5) aws-sdk-ecr (1.125.0) From a8be09e59aaaa5311c5184972d8fd4be11644146 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Wed, 29 Apr 2026 16:24:35 +0100 Subject: [PATCH 66/74] Destroy `SchoolMoves` when destroying team locations This should fix an invalid foreign key error. Sentry-Issue: 7449417734 --- .../api/testing/teams_controller.rb | 1 + .../teams_controller_locations_spec.rb | 62 ------------------- .../api/testing/teams_controller_spec.rb | 54 +++++++++++++++- 3 files changed, 54 insertions(+), 63 deletions(-) delete mode 100644 spec/controllers/api/testing/teams_controller_locations_spec.rb diff --git a/app/controllers/api/testing/teams_controller.rb b/app/controllers/api/testing/teams_controller.rb index 8ae41a7af7..34083f5135 100644 --- a/app/controllers/api/testing/teams_controller.rb +++ b/app/controllers/api/testing/teams_controller.rb @@ -92,6 +92,7 @@ def destroy_locations GillickAssessment.where(location_id: location_ids_to_delete).delete_all PatientLocation.where(location_id: location_ids_to_delete).delete_all PreScreening.where(location_id: location_ids_to_delete).delete_all + SchoolMove.where(school_id: location_ids_to_delete).delete_all team_location_ids = TeamLocation.where(location_id: location_ids_to_delete).pluck(:id) diff --git a/spec/controllers/api/testing/teams_controller_locations_spec.rb b/spec/controllers/api/testing/teams_controller_locations_spec.rb deleted file mode 100644 index aa45fd47ce..0000000000 --- a/spec/controllers/api/testing/teams_controller_locations_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -# frozen_string_literal: true - -describe API::Testing::TeamsController do - include ActiveJob::TestHelper - - before { Flipper.enable(:testing_api) } - after { Flipper.disable(:testing_api) } - - around { |example| travel_to(Date.new(2025, 7, 31)) { example.run } } - - describe "DELETE #destroy_locations" do - let(:team) { create(:team, workgroup: "r1l") } - - let!(:base_location) do - create(:gias_school, team:, name: "Hogwarts", urn: "123456") - end - - let!(:site_location) do - create(:gias_school, team:, name: "Hogwarts 2", urn: "123456", site: "B") - end - - context "when keep_base_locations is true" do - subject(:call) do - delete :destroy_locations, - params: { - workgroup: "r1l", - keep_base_locations: "true" - } - end - - it "keeps the base location and removes the site designation" do - expect { call }.to(change(Location, :count).by(-1)) - - expect(Location.find(base_location.id).site).to be_nil - expect { Location.find(site_location.id) }.to raise_error( - ActiveRecord::RecordNotFound - ) - end - end - - context "when keep_base_locations is false" do - subject(:call) do - delete :destroy_locations, - params: { - workgroup: "r1l", - keep_base_locations: "false" - } - end - - it "deletes all locations" do - expect { call }.to change(Location.gias_school, :count).by(-2) - - expect { Location.find(base_location.id) }.to raise_error( - ActiveRecord::RecordNotFound - ) - expect { Location.find(site_location.id) }.to raise_error( - ActiveRecord::RecordNotFound - ) - end - end - end -end diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb index b63eefc21b..6b2768f4ec 100644 --- a/spec/controllers/api/testing/teams_controller_spec.rb +++ b/spec/controllers/api/testing/teams_controller_spec.rb @@ -13,7 +13,7 @@ around { |example| travel_to(Date.new(2025, 7, 31)) { example.run } } - describe "DELETE" do + describe "DELETE /:workgroup" do let(:programmes) { [Programme.hpv] } let(:team) { create(:team, ods_code: "R1L", workgroup: "r1l", programmes:) } @@ -119,4 +119,56 @@ it_behaves_like "a method that updates team cached counts" end end + + describe "DELETE /:workgroup/locations" do + let(:team) { create(:team, workgroup: "r1l") } + + let!(:base_location) do + create(:gias_school, team:, name: "Hogwarts", urn: "123456") + end + + let!(:site_location) do + create(:gias_school, team:, name: "Hogwarts 2", urn: "123456", site: "B") + end + + context "when keep_base_locations is true" do + subject(:call) do + delete :destroy_locations, + params: { + workgroup: "r1l", + keep_base_locations: "true" + } + end + + it "keeps the base location and removes the site designation" do + expect { call }.to(change(Location, :count).by(-1)) + + expect(Location.find(base_location.id).site).to be_nil + expect { Location.find(site_location.id) }.to raise_error( + ActiveRecord::RecordNotFound + ) + end + end + + context "when keep_base_locations is false" do + subject(:call) do + delete :destroy_locations, + params: { + workgroup: "r1l", + keep_base_locations: "false" + } + end + + it "deletes all locations" do + expect { call }.to change(Location.gias_school, :count).by(-2) + + expect { Location.find(base_location.id) }.to raise_error( + ActiveRecord::RecordNotFound + ) + expect { Location.find(site_location.id) }.to raise_error( + ActiveRecord::RecordNotFound + ) + end + end + end end From b5b233021da0206c220e908582f1c150689f81f8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 30 Apr 2026 07:57:07 +0100 Subject: [PATCH 67/74] Rename `have_delivered` matchers This renames the matches to be worded in the present tense (`deliver_email` instead of `have_delivered_email`). This matches the naming convention for `sidekiq-rspec` where the present tense version is used with a block and the past tense version is used after the code has executed. Jira-Issue: MAV-7288 --- .../already_had_notification_sender_spec.rb | 4 +- spec/lib/notifier/consent_form_spec.rb | 52 ++--- spec/lib/notifier/consent_spec.rb | 54 ++--- spec/lib/notifier/patient_spec.rb | 198 +++++++++--------- spec/lib/notifier/vaccination_record_spec.rb | 32 +-- spec/models/session_notification_spec.rb | 10 +- ...ve_delivered_email.rb => deliver_email.rb} | 2 +- .../{have_delivered_sms.rb => deliver_sms.rb} | 2 +- 8 files changed, 172 insertions(+), 182 deletions(-) rename spec/support/matchers/{have_delivered_email.rb => deliver_email.rb} (94%) rename spec/support/matchers/{have_delivered_sms.rb => deliver_sms.rb} (94%) diff --git a/spec/lib/already_had_notification_sender_spec.rb b/spec/lib/already_had_notification_sender_spec.rb index b8d29d735c..d6a7d14526 100644 --- a/spec/lib/already_had_notification_sender_spec.rb +++ b/spec/lib/already_had_notification_sender_spec.rb @@ -22,8 +22,8 @@ before { ActiveJob::Base.queue_adapter.enqueued_jobs.clear } shared_examples "sends no notifications" do - it { expect { call }.not_to have_delivered_email } - it { expect { call }.not_to have_delivered_sms } + it { expect { call }.not_to deliver_email } + it { expect { call }.not_to deliver_sms } end shared_examples "sends one email to all parents with valid consents" do diff --git a/spec/lib/notifier/consent_form_spec.rb b/spec/lib/notifier/consent_form_spec.rb index 35b3d7f45e..dc5e2098fd 100644 --- a/spec/lib/notifier/consent_form_spec.rb +++ b/spec/lib/notifier/consent_form_spec.rb @@ -13,7 +13,7 @@ disease_types = consent_form.consent_form_programmes.flat_map(&:disease_types).uniq - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_given ).with(consent_form:, programme_types:, disease_types:) end @@ -23,7 +23,7 @@ disease_types = consent_form.consent_form_programmes.flat_map(&:disease_types).uniq - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :consent_confirmation_given ).with(consent_form:, programme_types:, disease_types:) end @@ -32,13 +32,13 @@ before { consent_form.update!(response: "refused") } it "sends an confirmation refused email" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_refused ).with(consent_form:) end it "sends a consent refused text" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :consent_confirmation_refused ).with(consent_form:) end @@ -59,13 +59,13 @@ end it "sends a confirmation given and a confirmation refused email" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_given ).with( consent_form:, programme_types: [menacwy_programme.type], disease_types: menacwy_programme.disease_types - ).and have_delivered_email(:consent_confirmation_refused).with( + ).and deliver_email(:consent_confirmation_refused).with( consent_form:, programme_types: [td_ipv_programme.type], disease_types: td_ipv_programme.disease_types @@ -73,13 +73,13 @@ end it "sends a confirmation given and a confirmation refused text" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :consent_confirmation_given ).with( consent_form:, programme_types: [menacwy_programme.type], disease_types: menacwy_programme.disease_types - ).and have_delivered_sms(:consent_confirmation_refused).with( + ).and deliver_sms(:consent_confirmation_refused).with( consent_form:, programme_types: [td_ipv_programme.type], disease_types: td_ipv_programme.disease_types @@ -95,13 +95,13 @@ disease_types = consent_form.consent_form_programmes.flat_map(&:disease_types).uniq - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_triage ).with(consent_form:, programme_types:, disease_types:) end it "doesn't send a text" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -125,13 +125,13 @@ disease_types = consent_form.consent_form_programmes.flat_map(&:disease_types).uniq - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_clinic ).with(consent_form:, programme_types:, disease_types:) end it "doesn't send a text" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -163,7 +163,7 @@ end it "sends confirmation email with disease_types including varicella" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_given ).with( consent_form:, @@ -197,9 +197,9 @@ end it "sends warning email and SMS to existing parent" do - expect { send_unknown_contact_details_warning }.to have_delivered_email( + expect { send_unknown_contact_details_warning }.to deliver_email( :consent_unknown_contact_details_warning - ).with(parent:, patient:, consent_form:).and have_delivered_sms( + ).with(parent:, patient:, consent_form:).and deliver_sms( :consent_unknown_contact_details_warning ).with(parent:, patient:, consent_form:) end @@ -215,15 +215,13 @@ end it "sends warning email" do - expect { send_unknown_contact_details_warning }.to have_delivered_email( + expect { send_unknown_contact_details_warning }.to deliver_email( :consent_unknown_contact_details_warning ).with(parent:, patient:, consent_form:) end it "does not send warning SMS" do - expect { - send_unknown_contact_details_warning - }.not_to have_delivered_sms + expect { send_unknown_contact_details_warning }.not_to deliver_sms end end @@ -239,21 +237,13 @@ let(:patient) { create(:patient, parents: [parent, parent2]) } it "sends warnings to all existing parents" do - expect { send_unknown_contact_details_warning }.to have_delivered_email( + expect { send_unknown_contact_details_warning }.to deliver_email( :consent_unknown_contact_details_warning - ).with(parent:, patient:, consent_form:).and have_delivered_email( + ).with(parent:, patient:, consent_form:).and deliver_email( :consent_unknown_contact_details_warning - ).with( - parent: parent2, - patient:, - consent_form: - ).and have_delivered_sms( + ).with(parent: parent2, patient:, consent_form:).and deliver_sms( :consent_unknown_contact_details_warning - ).with( - parent:, - patient:, - consent_form: - ).and have_delivered_sms( + ).with(parent:, patient:, consent_form:).and deliver_sms( :consent_unknown_contact_details_warning ).with(parent: parent2, patient:, consent_form:) end diff --git a/spec/lib/notifier/consent_spec.rb b/spec/lib/notifier/consent_spec.rb index 664f329cfe..5d67b1985f 100644 --- a/spec/lib/notifier/consent_spec.rb +++ b/spec/lib/notifier/consent_spec.rb @@ -22,13 +22,13 @@ context "when the parents agree, triage is required and it is safe to vaccinate" do it "sends an email saying triage was needed and vaccination will happen" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :triage_vaccination_will_happen ).with(consent:, session:, sent_by:) end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -42,7 +42,7 @@ end it "sends a different email tailored to MMR second dose" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :triage_vaccination_will_happen_mmr_second_dose ).with(consent:, session:, sent_by:) end @@ -59,13 +59,13 @@ end it "sends an email saying triage was needed but vaccination won't happen" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :triage_vaccination_wont_happen ).with(consent:, session:, sent_by:) end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -75,13 +75,13 @@ end it "sends an email saying triage was needed but vaccination won't happen" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :triage_vaccination_at_clinic ).with(consent:, session:, sent_by:) end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end context "when the team is Coventry & Warwickshire Partnership NHS Trust (CWPT)" do @@ -89,7 +89,7 @@ let(:team) { create(:team, ods_code: "RYG") } it "enqueues an email using the CWPT-specific template" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :triage_vaccination_at_clinic_ryg ).with(consent:, session:, sent_by:) end @@ -100,7 +100,7 @@ let(:team) { create(:team, ods_code: "RT5") } it "enqueues an email using the LPT-specific template" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :triage_vaccination_at_clinic_rt5 ).with(consent:, session:, sent_by:) end @@ -113,13 +113,13 @@ end it "sends an email saying vaccination will happen" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_given ).with(consent:, session:, sent_by:) end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :consent_confirmation_given ).with(consent:, session:, sent_by:) end @@ -129,13 +129,13 @@ let(:patient) { create(:patient, :consent_given_triage_needed, session:) } it "sends an email saying triage is required" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_triage ).with(consent:, session:, sent_by:) end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -143,11 +143,11 @@ let(:patient) { create(:patient, :consent_not_provided, session:) } it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -158,11 +158,11 @@ end it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -170,13 +170,13 @@ let(:patient) { create(:patient, :consent_refused, session:) } it "sends an email confirming they've refused consent" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_refused ).with(consent:, session:, sent_by:) end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :consent_confirmation_refused ).with(consent:, session:, sent_by:) end @@ -193,11 +193,11 @@ end it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -212,11 +212,11 @@ end it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -231,11 +231,11 @@ end it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -250,13 +250,13 @@ end it "sends an email" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :consent_confirmation_given ).with(consent:, session:, sent_by:) end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :consent_confirmation_given ).with(consent:, session:, sent_by:) end diff --git a/spec/lib/notifier/patient_spec.rb b/spec/lib/notifier/patient_spec.rb index 35edb9cf47..ac9a8e2dce 100644 --- a/spec/lib/notifier/patient_spec.rb +++ b/spec/lib/notifier/patient_spec.rb @@ -76,7 +76,7 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_hpv ).with( disease_types:, @@ -85,7 +85,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_email(:consent_school_request_hpv).with( + ).and deliver_email(:consent_school_request_hpv).with( disease_types:, parent: parents.second, patient:, @@ -96,7 +96,7 @@ end it "enqueues a text per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).with( disease_types:, @@ -105,7 +105,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_sms(:consent_school_request).with( + ).and deliver_sms(:consent_school_request).with( disease_types:, parent: parents.second, patient:, @@ -121,7 +121,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).with( disease_types:, @@ -138,13 +138,13 @@ let(:programmes) { [Programme.menacwy, Programme.td_ipv] } it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_doubles ).twice end it "enqueues an sms per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).twice end @@ -154,13 +154,13 @@ let(:programmes) { [Programme.flu] } it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_flu ).twice end it "enqueues an sms per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).twice end @@ -198,13 +198,13 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_mmrv ).twice end it "enqueues an sms per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request_mmr ).twice end @@ -215,13 +215,13 @@ end it "enqueues an outbreak email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_mmrv_outbreak ).twice end it "enqueues an sms" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request_mmr ).twice end @@ -234,13 +234,13 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_mmr ).twice end it "enqueues an sms" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request_mmr ).twice end @@ -251,13 +251,13 @@ end it "enqueues an outbreak email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_mmr_outbreak ).twice end it "enqueues an sms" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request_mmr ).twice end @@ -283,7 +283,7 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_clinic_request ).with( disease_types:, @@ -292,7 +292,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_email(:consent_clinic_request).with( + ).and deliver_email(:consent_clinic_request).with( disease_types:, parent: parents.second, patient:, @@ -303,7 +303,7 @@ end it "enqueues a text per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_clinic_request ).with( disease_types:, @@ -312,7 +312,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_sms(:consent_clinic_request).with( + ).and deliver_sms(:consent_clinic_request).with( disease_types:, parent: parents.second, patient:, @@ -328,7 +328,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_clinic_request ).with( disease_types:, @@ -367,7 +367,7 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_hpv ).with( disease_types:, @@ -376,7 +376,7 @@ programme_types:, team_location:, sent_by: - ).and have_delivered_email(:consent_school_request_hpv).with( + ).and deliver_email(:consent_school_request_hpv).with( disease_types:, parent: parents.second, patient:, @@ -387,7 +387,7 @@ end it "enqueues a text per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).with( disease_types:, @@ -396,7 +396,7 @@ programme_types:, team_location:, sent_by: - ).and have_delivered_sms(:consent_school_request).with( + ).and deliver_sms(:consent_school_request).with( disease_types:, parent: parents.second, patient:, @@ -412,7 +412,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).with( disease_types:, @@ -429,13 +429,13 @@ let(:programmes) { [Programme.menacwy, Programme.td_ipv] } it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_doubles ).twice end it "enqueues an sms per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).twice end @@ -445,13 +445,13 @@ let(:programmes) { [Programme.flu] } it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_flu ).twice end it "enqueues an sms per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request ).twice end @@ -489,13 +489,13 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_mmrv ).twice end it "enqueues an sms per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request_mmr ).twice end @@ -507,13 +507,13 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_school_request_mmr ).twice end it "enqueues an sms" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_school_request_mmr ).twice end @@ -538,7 +538,7 @@ end it "enqueues an email per parent" do - expect { send_consent_request }.to have_delivered_email( + expect { send_consent_request }.to deliver_email( :consent_clinic_request ).with( disease_types:, @@ -547,7 +547,7 @@ programme_types:, team_location:, sent_by: - ).and have_delivered_email(:consent_clinic_request).with( + ).and deliver_email(:consent_clinic_request).with( disease_types:, parent: parents.second, patient:, @@ -558,7 +558,7 @@ end it "enqueues a text per parent" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_clinic_request ).with( disease_types:, @@ -567,7 +567,7 @@ programme_types:, team_location:, sent_by: - ).and have_delivered_sms(:consent_clinic_request).with( + ).and deliver_sms(:consent_clinic_request).with( disease_types:, parent: parents.second, patient:, @@ -583,7 +583,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_consent_request }.to have_delivered_sms( + expect { send_consent_request }.to deliver_sms( :consent_clinic_request ).with( disease_types:, @@ -634,7 +634,7 @@ end it "enqueues an email per parent with the correct args" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_hpv ).with( disease_types:, @@ -643,7 +643,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_email(:consent_school_reminder_hpv).with( + ).and deliver_email(:consent_school_reminder_hpv).with( disease_types:, parent: parents.second, patient:, @@ -654,7 +654,7 @@ end it "enqueues a text per parent" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).with( disease_types:, @@ -663,7 +663,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_sms(:consent_school_reminder).with( + ).and deliver_sms(:consent_school_reminder).with( disease_types:, parent: parents.second, patient:, @@ -679,7 +679,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).with( disease_types:, @@ -696,13 +696,13 @@ let(:programmes) { [Programme.menacwy, Programme.td_ipv] } it "enqueues an email per parent" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_doubles ).twice end it "enqueues an sms per parent" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -712,13 +712,13 @@ let(:programmes) { [Programme.flu] } it "enqueues an email per parent" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_flu ).twice end it "enqueues an sms per parent" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -732,13 +732,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmr ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -749,13 +749,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmr ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -767,13 +767,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmrv ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -784,13 +784,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmrv ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -868,7 +868,7 @@ end it "enqueues an email per parent" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_hpv ).with( disease_types:, @@ -877,7 +877,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_email(:consent_school_reminder_hpv).with( + ).and deliver_email(:consent_school_reminder_hpv).with( disease_types:, parent: parents.second, patient:, @@ -888,7 +888,7 @@ end it "enqueues a text per parent" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).with( disease_types:, @@ -897,7 +897,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_sms(:consent_school_reminder).with( + ).and deliver_sms(:consent_school_reminder).with( disease_types:, parent: parents.second, patient:, @@ -913,7 +913,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).with( disease_types:, @@ -930,13 +930,13 @@ let(:programmes) { [Programme.menacwy, Programme.td_ipv] } it "enqueues an email per parent" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_doubles ).twice end it "enqueues an sms per parent" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -946,13 +946,13 @@ let(:programmes) { [Programme.flu] } it "enqueues an email per parent" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_flu ).twice end it "enqueues an sms per parent" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -967,13 +967,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmr ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -984,13 +984,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmr ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -1003,13 +1003,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmrv ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -1020,13 +1020,13 @@ end it "enqueues an email" do - expect { send_consent_reminder }.to have_delivered_email( + expect { send_consent_reminder }.to deliver_email( :consent_school_reminder_mmrv ).twice end it "enqueues an sms" do - expect { send_consent_reminder }.to have_delivered_sms( + expect { send_consent_reminder }.to deliver_sms( :consent_school_reminder ).twice end @@ -1189,7 +1189,7 @@ end it "enqueues an email per parent" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_initial_invitation ).with( parent: parents.first, @@ -1198,7 +1198,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_email(:clinic_initial_invitation).with( + ).and deliver_email(:clinic_initial_invitation).with( parent: parents.second, patient:, programme_types:, @@ -1209,7 +1209,7 @@ end it "enqueues a text per parent" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation ).with( parent: parents.first, @@ -1218,7 +1218,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_sms(:clinic_initial_invitation).with( + ).and deliver_sms(:clinic_initial_invitation).with( parent: parents.second, patient:, programme_types:, @@ -1262,7 +1262,7 @@ end it "only sends emails for the remaining programme" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_initial_invitation ).with( parent: parents.first, @@ -1275,7 +1275,7 @@ end it "enqueues a text per parent" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation ).with( parent: parents.first, @@ -1292,7 +1292,7 @@ let(:team) { create(:team, ods_code: "RYG", programmes:) } it "enqueues an email using the CWPT-specific template" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_initial_invitation_ryg ).with( parent: parents.first, @@ -1305,7 +1305,7 @@ end it "enqueues an SMS using the CWPT-specific template" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation_ryg ).with( parent: parents.first, @@ -1322,7 +1322,7 @@ let(:team) { create(:team, ods_code: "RT5", programmes:) } it "enqueues an email using the LPT-specific template" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_initial_invitation_rt5 ).with( parent: parents.first, @@ -1335,7 +1335,7 @@ end it "enqueues an SMS using the LPT-specific template" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation_rt5 ).with( parent: parents.first, @@ -1354,7 +1354,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation ).with( parent:, @@ -1394,7 +1394,7 @@ end it "enqueues an email per parent" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_subsequent_invitation ).with( parent: parents.first, @@ -1403,7 +1403,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_email(:clinic_subsequent_invitation).with( + ).and deliver_email(:clinic_subsequent_invitation).with( parent: parents.second, patient:, programme_types:, @@ -1414,7 +1414,7 @@ end it "enqueues a text per parent" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_subsequent_invitation ).with( parent: parents.first, @@ -1423,7 +1423,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_sms(:clinic_subsequent_invitation).with( + ).and deliver_sms(:clinic_subsequent_invitation).with( parent: parents.second, patient:, programme_types:, @@ -1439,7 +1439,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_subsequent_invitation ).with( parent:, @@ -1481,7 +1481,7 @@ end it "enqueues an email per parent" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_initial_invitation ).with( parent: parents.first, @@ -1490,7 +1490,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_email(:clinic_initial_invitation).with( + ).and deliver_email(:clinic_initial_invitation).with( parent: parents.second, patient:, programme_types:, @@ -1501,7 +1501,7 @@ end it "enqueues a text per parent" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation ).with( parent: parents.first, @@ -1510,7 +1510,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_sms(:clinic_initial_invitation).with( + ).and deliver_sms(:clinic_initial_invitation).with( parent: parents.second, patient:, programme_types:, @@ -1526,7 +1526,7 @@ before { parent.update!(phone_receive_updates: false) } it "still enqueues a text" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation ).with( parent:, @@ -1559,7 +1559,7 @@ end it "enqueues an email per parent" do - expect { send_clinic_invitation }.to have_delivered_email( + expect { send_clinic_invitation }.to deliver_email( :clinic_initial_invitation ).with( parent: parents.first, @@ -1568,7 +1568,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_email(:clinic_initial_invitation).with( + ).and deliver_email(:clinic_initial_invitation).with( parent: parents.second, patient:, programme_types: %w[hpv], @@ -1579,7 +1579,7 @@ end it "enqueues a text per parent" do - expect { send_clinic_invitation }.to have_delivered_sms( + expect { send_clinic_invitation }.to deliver_sms( :clinic_initial_invitation ).with( parent: parents.first, @@ -1588,7 +1588,7 @@ team:, academic_year:, sent_by: - ).and have_delivered_sms(:clinic_initial_invitation).with( + ).and deliver_sms(:clinic_initial_invitation).with( parent: parents.second, patient:, programme_types: %w[hpv], diff --git a/spec/lib/notifier/vaccination_record_spec.rb b/spec/lib/notifier/vaccination_record_spec.rb index eb862a9466..84eab18dea 100644 --- a/spec/lib/notifier/vaccination_record_spec.rb +++ b/spec/lib/notifier/vaccination_record_spec.rb @@ -25,13 +25,13 @@ before { create(:consent, :given, patient:, programme:) } it "sends an email" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :vaccination_administered ).with(parent:, vaccination_record:, sent_by:) end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :vaccination_administered ).with(parent:, vaccination_record:, sent_by:) end @@ -51,13 +51,13 @@ end it "sends an email" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :vaccination_not_administered ).with(parent:, vaccination_record:, sent_by:) end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :vaccination_not_administered ).with(parent:, vaccination_record:, sent_by:) end @@ -89,13 +89,13 @@ end it "sends an email" do - expect { send_confirmation }.to have_delivered_email( + expect { send_confirmation }.to deliver_email( :vaccination_administered ).with(parent:, vaccination_record:, sent_by:) end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms( + expect { send_confirmation }.to deliver_sms( :vaccination_administered ).with(parent:, vaccination_record:, sent_by:) end @@ -107,11 +107,11 @@ let(:notify_parents) { false } it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end end @@ -122,11 +122,11 @@ before { create(:consent, :given, patient:, programme:) } it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -136,11 +136,11 @@ before { create(:consent, :given, patient:, programme:) } it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -150,11 +150,11 @@ before { create(:consent, :given, patient:, programme:) } it "doesn't send an email" do - expect { send_confirmation }.not_to have_delivered_email + expect { send_confirmation }.not_to deliver_email end it "doesn't send a text message" do - expect { send_confirmation }.not_to have_delivered_sms + expect { send_confirmation }.not_to deliver_sms end end @@ -164,11 +164,11 @@ before { create(:consent, :given, patient:, programme:) } it "sends an email" do - expect { send_confirmation }.to have_delivered_email + expect { send_confirmation }.to deliver_email end it "sends a text message" do - expect { send_confirmation }.to have_delivered_sms + expect { send_confirmation }.to deliver_sms end end end diff --git a/spec/models/session_notification_spec.rb b/spec/models/session_notification_spec.rb index d8fe03b8b0..a176752f57 100644 --- a/spec/models/session_notification_spec.rb +++ b/spec/models/session_notification_spec.rb @@ -72,7 +72,7 @@ end it "enqueues an email per parent who gave consent" do - expect { create_and_send! }.to have_delivered_email( + expect { create_and_send! }.to deliver_email( :session_school_reminder ).with( parent:, @@ -84,7 +84,7 @@ end it "enqueues a text per parent" do - expect { create_and_send! }.to have_delivered_sms( + expect { create_and_send! }.to deliver_sms( :session_school_reminder ).with( parent:, @@ -99,7 +99,7 @@ before { parents.each { it.update!(phone_receive_updates: false) } } it "doesn't enqueues a text" do - expect { create_and_send! }.not_to have_delivered_sms + expect { create_and_send! }.not_to deliver_sms end end @@ -110,7 +110,7 @@ let(:programmes) { consented_programmes + [Programme.menacwy] } it "enqueues an email per parent who gave consent" do - expect { create_and_send! }.to have_delivered_email( + expect { create_and_send! }.to deliver_email( :session_school_reminder ).with( parent:, @@ -122,7 +122,7 @@ end it "enqueues a text per parent" do - expect { create_and_send! }.to have_delivered_sms( + expect { create_and_send! }.to deliver_sms( :session_school_reminder ).with( parent:, diff --git a/spec/support/matchers/have_delivered_email.rb b/spec/support/matchers/deliver_email.rb similarity index 94% rename from spec/support/matchers/have_delivered_email.rb rename to spec/support/matchers/deliver_email.rb index 8fbe45da79..c03adb2bb9 100644 --- a/spec/support/matchers/have_delivered_email.rb +++ b/spec/support/matchers/deliver_email.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec::Matchers.matcher :have_delivered_email do |template_name = nil| +RSpec::Matchers.matcher :deliver_email do |template_name = nil| supports_block_expectations define_singleton_method :chain_delegate do |*methods| diff --git a/spec/support/matchers/have_delivered_sms.rb b/spec/support/matchers/deliver_sms.rb similarity index 94% rename from spec/support/matchers/have_delivered_sms.rb rename to spec/support/matchers/deliver_sms.rb index f09ff5ef30..a138dc3d71 100644 --- a/spec/support/matchers/have_delivered_sms.rb +++ b/spec/support/matchers/deliver_sms.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec::Matchers.matcher :have_delivered_sms do |template_name = nil| +RSpec::Matchers.matcher :deliver_sms do |template_name = nil| supports_block_expectations define_singleton_method :chain_delegate do |*methods| From eb94913bb1b4975e13873b4166ceeacd93f3193f Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 30 Apr 2026 08:11:15 +0100 Subject: [PATCH 68/74] Use `delivery` matchers This is a small refactor to some tests to use the delivery matches rather than checking for the presence of specific jobs. This will make it easier to refactor these jobs to use Sidekiq as we'll only need to change the matchers. Jira-Issue: MAV-7288 --- app/lib/already_had_notification_sender.rb | 6 +- .../already_had_notification_sender_spec.rb | 107 ++++-------------- spec/lib/notifier/patient_spec.rb | 32 ++---- 3 files changed, 36 insertions(+), 109 deletions(-) diff --git a/app/lib/already_had_notification_sender.rb b/app/lib/already_had_notification_sender.rb index 55e82d0bbe..e6e91f3655 100644 --- a/app/lib/already_had_notification_sender.rb +++ b/app/lib/already_had_notification_sender.rb @@ -24,7 +24,7 @@ def call parents_with_consent = NotificationParentSelector.new( - vaccination_record: @vaccination_record, + vaccination_record:, consents: ).parents_with_consent @@ -33,7 +33,7 @@ def call SMSDeliveryJob.perform_later( :vaccination_already_had, parent:, - vaccination_record: @vaccination_record, + vaccination_record:, consent: ) end @@ -41,7 +41,7 @@ def call EmailDeliveryJob.perform_later( :vaccination_already_had, parent:, - vaccination_record: @vaccination_record, + vaccination_record:, consent: ) diff --git a/spec/lib/already_had_notification_sender_spec.rb b/spec/lib/already_had_notification_sender_spec.rb index d6a7d14526..c1ab1225ad 100644 --- a/spec/lib/already_had_notification_sender_spec.rb +++ b/spec/lib/already_had_notification_sender_spec.rb @@ -28,44 +28,29 @@ shared_examples "sends one email to all parents with valid consents" do it "sends email notifications to all parents with valid consents" do - call - - expect(EmailDeliveryJob).to have_been_enqueued - .with( - :vaccination_already_had, - parent: first_parent, - vaccination_record:, - consent: first_consent - ) - .exactly(1) - .times - expect(EmailDeliveryJob).to have_been_enqueued - .with( - :vaccination_already_had, - parent: second_parent, - vaccination_record:, - consent: second_consent - ) - .exactly(1) - .times + expect { call }.to deliver_email(:vaccination_already_had).with( + parent: first_parent, + vaccination_record:, + consent: first_consent + ).once.and deliver_email(:vaccination_already_had).with( + parent: second_parent, + vaccination_record:, + consent: second_consent + ).once end end shared_examples "sends one SMS only to opted-in parents" do - it "sends SMS notifications only to parents who opted in for updates" do - call - - expect(SMSDeliveryJob).to have_been_enqueued - .with( - :vaccination_already_had, - parent: first_parent, - vaccination_record:, - consent: first_consent - ) - .exactly(1) - .times - expect(SMSDeliveryJob).not_to have_been_enqueued.with( - :vaccination_already_had, + it "sends SMS notifications to parents who opted in for updates" do + expect { call }.to deliver_sms(:vaccination_already_had).with( + parent: first_parent, + vaccination_record:, + consent: first_consent + ) + end + + it "doesn't send SMS notifications to parents who haven't opted in" do + expect { call }.not_to deliver_sms(:vaccination_already_had).with( parent: second_parent, vaccination_record:, consent: second_consent @@ -74,7 +59,7 @@ end shared_context "with valid consents" do - let!(:first_consent) do + let(:first_consent) do create( :consent, :given, @@ -84,7 +69,7 @@ academic_year: ) end - let!(:second_consent) do + let(:second_consent) do create( :consent, :given, @@ -94,18 +79,6 @@ academic_year: ) end - let(:first_parent_job_args) do - [ - :vaccination_already_had, - { parent: first_parent, vaccination_record:, consent: first_consent } - ] - end - let(:second_parent_job_args) do - [ - :vaccination_already_had, - { parent: second_parent, vaccination_record:, consent: second_consent } - ] - end end describe "#call" do @@ -198,14 +171,7 @@ end it "ignores invalidated consents" do - call - - expect(EmailDeliveryJob).not_to have_been_enqueued.with( - *first_parent_job_args - ) - expect(EmailDeliveryJob).to have_been_enqueued.with( - *second_parent_job_args - ) + expect { call }.not_to deliver_email.with(parent: first_parent) end end @@ -218,14 +184,7 @@ end it "ignores refused consents" do - call - - expect(EmailDeliveryJob).not_to have_been_enqueued.with( - *first_parent_job_args - ) - expect(EmailDeliveryJob).to have_been_enqueued.with( - *second_parent_job_args - ) + expect { call }.not_to deliver_email.with(parent: first_parent) end end @@ -238,14 +197,7 @@ end it "excludes consents notified after this vaccination record" do - call - - expect(EmailDeliveryJob).not_to have_been_enqueued.with( - *first_parent_job_args - ) - expect(EmailDeliveryJob).to have_been_enqueued.with( - *second_parent_job_args - ) + expect { call }.not_to deliver_email.with(parent: first_parent) end end @@ -257,16 +209,7 @@ ) end - it "includes consents notified before this vaccination record" do - call - - expect(EmailDeliveryJob).to have_been_enqueued.with( - *first_parent_job_args - ) - expect(EmailDeliveryJob).to have_been_enqueued.with( - *second_parent_job_args - ) - end + include_examples "sends one email to all parents with valid consents" end end diff --git a/spec/lib/notifier/patient_spec.rb b/spec/lib/notifier/patient_spec.rb index ac9a8e2dce..f3e971b7f4 100644 --- a/spec/lib/notifier/patient_spec.rb +++ b/spec/lib/notifier/patient_spec.rb @@ -180,15 +180,11 @@ end it "does not enqueue an email" do - expect { send_consent_request }.not_to have_enqueued_job( - EmailDeliveryJob - ) + expect { send_consent_request }.not_to deliver_email end it "does not enqueue an SMS" do - expect { send_consent_request }.not_to have_enqueued_job( - SMSDeliveryJob - ) + expect { send_consent_request }.not_to deliver_sms end end @@ -471,15 +467,11 @@ end it "does not enqueue an email" do - expect { send_consent_request }.not_to have_enqueued_job( - EmailDeliveryJob - ) + expect { send_consent_request }.not_to deliver_email end it "does not enqueue an SMS" do - expect { send_consent_request }.not_to have_enqueued_job( - SMSDeliveryJob - ) + expect { send_consent_request }.not_to deliver_sms end end @@ -808,15 +800,11 @@ end it "does not enqueue an email" do - expect { send_consent_reminder }.not_to have_enqueued_job( - EmailDeliveryJob - ) + expect { send_consent_reminder }.not_to deliver_email end it "does not enqueue an SMS" do - expect { send_consent_reminder }.not_to have_enqueued_job( - SMSDeliveryJob - ) + expect { send_consent_reminder }.not_to deliver_sms end end end @@ -1241,15 +1229,11 @@ end it "does not enqueue an email" do - expect { send_clinic_invitation }.not_to have_enqueued_job( - EmailDeliveryJob - ) + expect { send_clinic_invitation }.not_to deliver_email end it "does not enqueue an SMS" do - expect { send_clinic_invitation }.not_to have_enqueued_job( - SMSDeliveryJob - ) + expect { send_clinic_invitation }.not_to deliver_sms end end From 60496a11f04134f659623ea5ce03f30305bf6787 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 30 Apr 2026 08:23:32 +0100 Subject: [PATCH 69/74] Convert `/api/testing/refresh-reporting` specs This converts the specs from controller tests to request tests. Generally request tests are preferred as they test the entire stack including routing and query parameter parsing. Controller specs are not recommended by RSpec: https://rspec.info/blog/2016/07/rspec-3-5-has-been-released/ --- ...ler.rb => refresh_reporting_controller.rb} | 2 +- config/routes.rb | 2 +- .../reporting_refresh_controller_spec.rb | 20 -------------- .../api/testing/refresh_reporting_spec.rb | 26 +++++++++++++++++++ .../vaccinations_search_in_nhs_spec.rb | 1 - 5 files changed, 28 insertions(+), 23 deletions(-) rename app/controllers/api/testing/{reporting_refresh_controller.rb => refresh_reporting_controller.rb} (80%) delete mode 100644 spec/controllers/api/testing/reporting_refresh_controller_spec.rb create mode 100644 spec/requests/api/testing/refresh_reporting_spec.rb diff --git a/app/controllers/api/testing/reporting_refresh_controller.rb b/app/controllers/api/testing/refresh_reporting_controller.rb similarity index 80% rename from app/controllers/api/testing/reporting_refresh_controller.rb rename to app/controllers/api/testing/refresh_reporting_controller.rb index 62e38a4782..22753cea58 100644 --- a/app/controllers/api/testing/reporting_refresh_controller.rb +++ b/app/controllers/api/testing/refresh_reporting_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class API::Testing::ReportingRefreshController < API::Testing::BaseController +class API::Testing::RefreshReportingController < API::Testing::BaseController def create if params[:wait].present? ReportingAPI::RefreshJob.perform_now diff --git a/config/routes.rb b/config/routes.rb index bef7aff51a..220a88694c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -115,7 +115,7 @@ delete "teams/:workgroup/locations", to: "teams#destroy_locations" resources :teams, only: :destroy, param: :workgroup post "/onboard", to: "onboard#create" - get "refresh-reporting", to: "reporting_refresh#create" + get "refresh-reporting", to: "refresh_reporting#create" post "vaccinations-search-in-nhs", to: "vaccinations_search_in_nhs#create" end diff --git a/spec/controllers/api/testing/reporting_refresh_controller_spec.rb b/spec/controllers/api/testing/reporting_refresh_controller_spec.rb deleted file mode 100644 index 5538fbdb72..0000000000 --- a/spec/controllers/api/testing/reporting_refresh_controller_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -describe API::Testing::ReportingRefreshController do - describe "#create" do - it "performs the refresh job and responds with accepted status" do - expect(ReportingAPI::RefreshJob).to receive(:perform_later) - get :create - expect(response).to have_http_status(:accepted) - end - - context "when wait=true" do - it "runs the refresh synchronously and responds with ok status" do - expect(ReportingAPI::RefreshJob).to receive(:perform_now) - expect(ReportingAPI::RefreshJob).not_to receive(:perform_later) - get :create, params: { wait: "true" } - expect(response).to have_http_status(:ok) - end - end - end -end diff --git a/spec/requests/api/testing/refresh_reporting_spec.rb b/spec/requests/api/testing/refresh_reporting_spec.rb new file mode 100644 index 0000000000..8c6b146d2a --- /dev/null +++ b/spec/requests/api/testing/refresh_reporting_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +describe "/api/testing/refresh-reporting" do + before { Flipper.enable(:testing_api) } + + describe "GET" do + context "without wait param" do + it "enqueues the job and responds with accepted" do + expect { get "/api/testing/refresh-reporting" }.to have_enqueued_job( + ReportingAPI::RefreshJob + ) + expect(response).to have_http_status(:accepted) + end + end + + context "with wait=true" do + before { allow(ReportingAPI::RefreshJob).to receive(:perform_now) } + + it "runs the job synchronously and responds with ok" do + get "/api/testing/refresh-reporting", params: { wait: "true" } + expect(ReportingAPI::RefreshJob).to have_received(:perform_now) + expect(response).to have_http_status(:ok) + end + end + end +end diff --git a/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb b/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb index ae49ce3b63..90ba3956ed 100644 --- a/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb +++ b/spec/requests/api/testing/vaccinations_search_in_nhs_spec.rb @@ -2,7 +2,6 @@ describe "/api/testing/vaccinations-search-in-nhs" do before { Flipper.enable(:testing_api) } - after { Flipper.disable(:testing_api) } describe "POST" do context "without wait param" do From 3d8ab8a40ff26b42796835f952005bd358c1e8c7 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 30 Apr 2026 08:26:35 +0100 Subject: [PATCH 70/74] Convert `/api/testing/teams` specs This converts the specs from controller tests to request tests. Generally request tests are preferred as they test the entire stack including routing and query parameter parsing. Controller specs are not recommended by RSpec: https://rspec.info/blog/2016/07/rspec-3-5-has-been-released/ --- config/routes.rb | 9 ++++++--- .../api/testing/teams_spec.rb} | 18 +++++------------- 2 files changed, 11 insertions(+), 16 deletions(-) rename spec/{controllers/api/testing/teams_controller_spec.rb => requests/api/testing/teams_spec.rb} (90%) diff --git a/config/routes.rb b/config/routes.rb index 220a88694c..3cb94d1314 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -111,10 +111,13 @@ namespace :api do unless Rails.env.production? namespace :testing do + post "onboard", to: "onboard#create" + resources :locations, only: :index - delete "teams/:workgroup/locations", to: "teams#destroy_locations" - resources :teams, only: :destroy, param: :workgroup - post "/onboard", to: "onboard#create" + resources :teams, only: :destroy, param: :workgroup do + delete "locations", action: :destroy_locations, on: :member + end + get "refresh-reporting", to: "refresh_reporting#create" post "vaccinations-search-in-nhs", to: "vaccinations_search_in_nhs#create" diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/requests/api/testing/teams_spec.rb similarity index 90% rename from spec/controllers/api/testing/teams_controller_spec.rb rename to spec/requests/api/testing/teams_spec.rb index 6b2768f4ec..74f2a0d956 100644 --- a/spec/controllers/api/testing/teams_controller_spec.rb +++ b/spec/requests/api/testing/teams_spec.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -describe API::Testing::TeamsController do - include ActiveJob::TestHelper +describe "/api/testing/teams" do include ImportsHelper before do @@ -75,7 +74,7 @@ end context "when not keeping itself" do - subject(:call) { delete :destroy, params: { workgroup: "r1l" } } + subject(:call) { delete "/api/testing/teams/r1l" } it "deletes associated data" do expect { call }.to( @@ -98,7 +97,7 @@ context "when keeping itself" do subject(:call) do - delete :destroy, params: { workgroup: "r1l", keep_itself: "true" } + delete "/api/testing/teams/r1l", params: { keep_itself: "true" } end it "deletes associated data" do @@ -133,9 +132,8 @@ context "when keep_base_locations is true" do subject(:call) do - delete :destroy_locations, + delete "/api/testing/teams/r1l/locations", params: { - workgroup: "r1l", keep_base_locations: "true" } end @@ -151,13 +149,7 @@ end context "when keep_base_locations is false" do - subject(:call) do - delete :destroy_locations, - params: { - workgroup: "r1l", - keep_base_locations: "false" - } - end + subject(:call) { delete "/api/testing/teams/r1l/locations" } it "deletes all locations" do expect { call }.to change(Location.gias_school, :count).by(-2) From 1e331fee489007c54fdcb8ec3374bdc731948bd5 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 28 Apr 2026 21:05:55 +0100 Subject: [PATCH 71/74] Refactor `ApplicationJob` This refactors the `ApplicationJob` in to two variants, `ApplicationJobActiveJob` and `ApplicationJobSidekiq`, according to how the job is defined. We're making this change to support migrating away from using Active Job and instead using the Sidekiq API directly, which gives us a number of benefits. We can't just switch over in one go, as any Active Job jobs left in the queue will fail to run once the job class has been converted. So instead, we need two versions of each job, and then only once we no longer have any Active Job jobs in the queue, we can remove the Active Job variations. Jira-Issue: MAV-7288 --- app/jobs/application_job.rb | 8 -------- app/jobs/application_job_active_job.rb | 7 +++++++ app/jobs/application_job_sidekiq.rb | 5 +++++ app/jobs/bulk_remove_parent_relationships_job.rb | 2 +- app/jobs/commit_patient_changesets_job.rb | 6 ++---- app/jobs/enqueue_automated_careplus_reports_job.rb | 2 +- app/jobs/enqueue_location_position_updater_job.rb | 2 +- app/jobs/enqueue_patients_aged_out_of_schools_job.rb | 2 +- app/jobs/enqueue_process_unmatched_consent_forms_job.rb | 2 +- app/jobs/enqueue_school_consent_reminders_job.rb | 2 +- app/jobs/enqueue_school_consent_requests_job.rb | 2 +- app/jobs/enqueue_school_session_reminders_job.rb | 2 +- app/jobs/enqueue_update_patients_from_pds_job.rb | 2 +- app/jobs/enqueue_vaccinations_search_in_nhs_job.rb | 2 +- app/jobs/gias_import_job.rb | 2 +- app/jobs/important_notice_generator_job.rb | 2 +- app/jobs/invalidate_self_consents_job.rb | 2 +- app/jobs/location_position_updater_job.rb | 4 +--- app/jobs/metrics/base_job.rb | 4 +--- app/jobs/notify_delivery_job.rb | 2 +- app/jobs/patient_nhs_number_lookup_job.rb | 2 +- app/jobs/patient_status_updater_job.rb | 4 +--- app/jobs/patient_team_updater_job.rb | 4 +--- app/jobs/patient_update_from_pds_job.rb | 2 +- app/jobs/patients_aged_out_of_school_job.rb | 4 +--- app/jobs/patients_clear_registration_job.rb | 2 +- .../patients_refused_consent_already_vaccinated_job.rb | 2 +- app/jobs/pds_cascading_search_job.rb | 2 +- app/jobs/process_consent_form_job.rb | 2 +- app/jobs/process_import_job.rb | 2 +- app/jobs/process_patient_changeset_job.rb | 2 +- app/jobs/remove_import_csv_job.rb | 2 +- app/jobs/reporting_api/refresh_job.rb | 2 +- app/jobs/review_class_import_school_move_job.rb | 2 +- app/jobs/review_patient_changeset_job.rb | 2 +- app/jobs/search_vaccination_records_in_nhs_job.rb | 3 +-- app/jobs/send_automated_careplus_reports_job.rb | 4 +--- app/jobs/send_automatic_school_consent_reminders_job.rb | 2 +- app/jobs/send_manual_school_consent_reminders_job.rb | 2 +- app/jobs/send_school_consent_requests_job.rb | 2 +- app/jobs/send_school_session_reminders_job.rb | 2 +- app/jobs/send_vaccination_confirmations_job.rb | 2 +- app/jobs/sync_vaccination_record_to_nhs_job.rb | 3 +-- app/jobs/trim_active_record_sessions_job.rb | 2 +- 44 files changed, 54 insertions(+), 66 deletions(-) delete mode 100644 app/jobs/application_job.rb create mode 100644 app/jobs/application_job_active_job.rb create mode 100644 app/jobs/application_job_sidekiq.rb diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb deleted file mode 100644 index 70ce54143a..0000000000 --- a/app/jobs/application_job.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -class ApplicationJob < ActiveJob::Base - # Automatically retry jobs that encountered a deadlock - # retry_on ActiveRecord::Deadlocked - - discard_on ActiveJob::DeserializationError -end diff --git a/app/jobs/application_job_active_job.rb b/app/jobs/application_job_active_job.rb new file mode 100644 index 0000000000..d635211654 --- /dev/null +++ b/app/jobs/application_job_active_job.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# rubocop:disable Rails/ApplicationJob +class ApplicationJobActiveJob < ActiveJob::Base + discard_on ActiveJob::DeserializationError +end +# rubocop:enable Rails/ApplicationJob diff --git a/app/jobs/application_job_sidekiq.rb b/app/jobs/application_job_sidekiq.rb new file mode 100644 index 0000000000..bec3de603c --- /dev/null +++ b/app/jobs/application_job_sidekiq.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ApplicationJobSidekiq + include Sidekiq::Job +end diff --git a/app/jobs/bulk_remove_parent_relationships_job.rb b/app/jobs/bulk_remove_parent_relationships_job.rb index 220745e87d..55c4d0b7ff 100644 --- a/app/jobs/bulk_remove_parent_relationships_job.rb +++ b/app/jobs/bulk_remove_parent_relationships_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class BulkRemoveParentRelationshipsJob < ApplicationJob +class BulkRemoveParentRelationshipsJob < ApplicationJobActiveJob queue_as :imports def perform( diff --git a/app/jobs/commit_patient_changesets_job.rb b/app/jobs/commit_patient_changesets_job.rb index 4c80fdac5f..43d1d45c41 100644 --- a/app/jobs/commit_patient_changesets_job.rb +++ b/app/jobs/commit_patient_changesets_job.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true -class CommitPatientChangesetsJob - include Sidekiq::Job - include Sidekiq::Throttled::Job +class CommitPatientChangesetsJob < ApplicationJobSidekiq include PatientImportConcern - queue_as :imports + sidekiq_options queue: :imports def perform(patient_changeset_ids) changesets = diff --git a/app/jobs/enqueue_automated_careplus_reports_job.rb b/app/jobs/enqueue_automated_careplus_reports_job.rb index c7924640c2..2be20986dd 100644 --- a/app/jobs/enqueue_automated_careplus_reports_job.rb +++ b/app/jobs/enqueue_automated_careplus_reports_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueAutomatedCareplusReportsJob < ApplicationJob +class EnqueueAutomatedCareplusReportsJob < ApplicationJobActiveJob queue_as :careplus def perform diff --git a/app/jobs/enqueue_location_position_updater_job.rb b/app/jobs/enqueue_location_position_updater_job.rb index 3be3bce77f..e27165ac01 100644 --- a/app/jobs/enqueue_location_position_updater_job.rb +++ b/app/jobs/enqueue_location_position_updater_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueLocationPositionUpdaterJob < ApplicationJob +class EnqueueLocationPositionUpdaterJob < ApplicationJobActiveJob queue_as :third_party_data_imports def perform diff --git a/app/jobs/enqueue_patients_aged_out_of_schools_job.rb b/app/jobs/enqueue_patients_aged_out_of_schools_job.rb index 329fa2aa4a..3304069786 100644 --- a/app/jobs/enqueue_patients_aged_out_of_schools_job.rb +++ b/app/jobs/enqueue_patients_aged_out_of_schools_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueuePatientsAgedOutOfSchoolsJob < ApplicationJob +class EnqueuePatientsAgedOutOfSchoolsJob < ApplicationJobActiveJob queue_as :patients def perform diff --git a/app/jobs/enqueue_process_unmatched_consent_forms_job.rb b/app/jobs/enqueue_process_unmatched_consent_forms_job.rb index 9ab02734fa..3436735aa2 100644 --- a/app/jobs/enqueue_process_unmatched_consent_forms_job.rb +++ b/app/jobs/enqueue_process_unmatched_consent_forms_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueProcessUnmatchedConsentFormsJob < ApplicationJob +class EnqueueProcessUnmatchedConsentFormsJob < ApplicationJobActiveJob include SingleConcurrencyConcern queue_as :consents diff --git a/app/jobs/enqueue_school_consent_reminders_job.rb b/app/jobs/enqueue_school_consent_reminders_job.rb index e3ca424490..4fe4f50597 100644 --- a/app/jobs/enqueue_school_consent_reminders_job.rb +++ b/app/jobs/enqueue_school_consent_reminders_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueSchoolConsentRemindersJob < ApplicationJob +class EnqueueSchoolConsentRemindersJob < ApplicationJobActiveJob queue_as :notifications def perform diff --git a/app/jobs/enqueue_school_consent_requests_job.rb b/app/jobs/enqueue_school_consent_requests_job.rb index 435f327366..44b42cada9 100644 --- a/app/jobs/enqueue_school_consent_requests_job.rb +++ b/app/jobs/enqueue_school_consent_requests_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueSchoolConsentRequestsJob < ApplicationJob +class EnqueueSchoolConsentRequestsJob < ApplicationJobActiveJob queue_as :notifications def perform diff --git a/app/jobs/enqueue_school_session_reminders_job.rb b/app/jobs/enqueue_school_session_reminders_job.rb index 7a50cda3e0..434ef9c167 100644 --- a/app/jobs/enqueue_school_session_reminders_job.rb +++ b/app/jobs/enqueue_school_session_reminders_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueSchoolSessionRemindersJob < ApplicationJob +class EnqueueSchoolSessionRemindersJob < ApplicationJobActiveJob queue_as :notifications def perform diff --git a/app/jobs/enqueue_update_patients_from_pds_job.rb b/app/jobs/enqueue_update_patients_from_pds_job.rb index da0ae9d742..e6c420a9a9 100644 --- a/app/jobs/enqueue_update_patients_from_pds_job.rb +++ b/app/jobs/enqueue_update_patients_from_pds_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class EnqueueUpdatePatientsFromPDSJob < ApplicationJob +class EnqueueUpdatePatientsFromPDSJob < ApplicationJobActiveJob include SingleConcurrencyConcern queue_as :pds diff --git a/app/jobs/enqueue_vaccinations_search_in_nhs_job.rb b/app/jobs/enqueue_vaccinations_search_in_nhs_job.rb index d9f7dc8630..52d0200b51 100644 --- a/app/jobs/enqueue_vaccinations_search_in_nhs_job.rb +++ b/app/jobs/enqueue_vaccinations_search_in_nhs_job.rb @@ -7,7 +7,7 @@ # sessions, starting from 2 days before invitations or consent requests are sent # out and ending once the last date of the sessions has passed. For all other # patients we want to ensure a search is performed every 28 days at most. -class EnqueueVaccinationsSearchInNHSJob < ApplicationJob +class EnqueueVaccinationsSearchInNHSJob < ApplicationJobActiveJob queue_as :immunisations_api_search def perform(programme_types: nil) diff --git a/app/jobs/gias_import_job.rb b/app/jobs/gias_import_job.rb index a4c49d2452..da419e913a 100644 --- a/app/jobs/gias_import_job.rb +++ b/app/jobs/gias_import_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class GIASImportJob < ApplicationJob +class GIASImportJob < ApplicationJobActiveJob include SingleConcurrencyConcern queue_as :third_party_data_imports diff --git a/app/jobs/important_notice_generator_job.rb b/app/jobs/important_notice_generator_job.rb index ed39bcf534..2f4f8f0b88 100644 --- a/app/jobs/important_notice_generator_job.rb +++ b/app/jobs/important_notice_generator_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ImportantNoticeGeneratorJob < ApplicationJob +class ImportantNoticeGeneratorJob < ApplicationJobActiveJob queue_as :cache BATCH_SIZE = 1000 diff --git a/app/jobs/invalidate_self_consents_job.rb b/app/jobs/invalidate_self_consents_job.rb index b3122980e4..7097e601e9 100644 --- a/app/jobs/invalidate_self_consents_job.rb +++ b/app/jobs/invalidate_self_consents_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class InvalidateSelfConsentsJob < ApplicationJob +class InvalidateSelfConsentsJob < ApplicationJobActiveJob queue_as :consents def perform diff --git a/app/jobs/location_position_updater_job.rb b/app/jobs/location_position_updater_job.rb index 8c1eaae802..60281dd48c 100644 --- a/app/jobs/location_position_updater_job.rb +++ b/app/jobs/location_position_updater_job.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class LocationPositionUpdaterJob - include Sidekiq::Job - +class LocationPositionUpdaterJob < ApplicationJobSidekiq sidekiq_options queue: :third_party_data_imports, lock: :until_executing def perform(location_id) diff --git a/app/jobs/metrics/base_job.rb b/app/jobs/metrics/base_job.rb index 49e039d851..3fc2db55c7 100644 --- a/app/jobs/metrics/base_job.rb +++ b/app/jobs/metrics/base_job.rb @@ -5,9 +5,7 @@ # # It configures the job to run on the `metrics` queue and does not retry if it # fails, allowing it to be scheduled regularly. -class Metrics::BaseJob - include Sidekiq::Job - +class Metrics::BaseJob < ApplicationJobSidekiq # We don't retry jobs that export metrics if they fail as they are often # scheduled to run regularly. sidekiq_options queue: :metrics, retry: false diff --git a/app/jobs/notify_delivery_job.rb b/app/jobs/notify_delivery_job.rb index a069253b58..2d386b5f54 100644 --- a/app/jobs/notify_delivery_job.rb +++ b/app/jobs/notify_delivery_job.rb @@ -2,7 +2,7 @@ require "notifications/client" -class NotifyDeliveryJob < ApplicationJob +class NotifyDeliveryJob < ApplicationJobActiveJob TEAM_ONLY_API_KEY_MESSAGE = "Can’t send to this recipient using a team-only API key" diff --git a/app/jobs/patient_nhs_number_lookup_job.rb b/app/jobs/patient_nhs_number_lookup_job.rb index 4be3ce3d65..c793ea9204 100644 --- a/app/jobs/patient_nhs_number_lookup_job.rb +++ b/app/jobs/patient_nhs_number_lookup_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PatientNHSNumberLookupJob < ApplicationJob +class PatientNHSNumberLookupJob < ApplicationJobActiveJob include PDSThrottlingConcern queue_as :pds diff --git a/app/jobs/patient_status_updater_job.rb b/app/jobs/patient_status_updater_job.rb index b2fd982fa9..1dc6d82b80 100644 --- a/app/jobs/patient_status_updater_job.rb +++ b/app/jobs/patient_status_updater_job.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class PatientStatusUpdaterJob - include Sidekiq::Job - +class PatientStatusUpdaterJob < ApplicationJobSidekiq sidekiq_options queue: :cache, lock: :until_executed def perform(patient_id = nil) diff --git a/app/jobs/patient_team_updater_job.rb b/app/jobs/patient_team_updater_job.rb index b4a3583159..27d4260119 100644 --- a/app/jobs/patient_team_updater_job.rb +++ b/app/jobs/patient_team_updater_job.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class PatientTeamUpdaterJob - include Sidekiq::Job - +class PatientTeamUpdaterJob < ApplicationJobSidekiq sidekiq_options queue: :cache, lock: :until_executed def perform(patient_id = nil, team_id = nil) diff --git a/app/jobs/patient_update_from_pds_job.rb b/app/jobs/patient_update_from_pds_job.rb index 5aa0beb7c3..56cee6ba81 100644 --- a/app/jobs/patient_update_from_pds_job.rb +++ b/app/jobs/patient_update_from_pds_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PatientUpdateFromPDSJob < ApplicationJob +class PatientUpdateFromPDSJob < ApplicationJobActiveJob include PDSThrottlingConcern queue_as :pds diff --git a/app/jobs/patients_aged_out_of_school_job.rb b/app/jobs/patients_aged_out_of_school_job.rb index 6f820320d4..52daef83cf 100644 --- a/app/jobs/patients_aged_out_of_school_job.rb +++ b/app/jobs/patients_aged_out_of_school_job.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class PatientsAgedOutOfSchoolJob - include Sidekiq::Job - +class PatientsAgedOutOfSchoolJob < ApplicationJobSidekiq sidekiq_options queue: :patients def perform(school_id) diff --git a/app/jobs/patients_clear_registration_job.rb b/app/jobs/patients_clear_registration_job.rb index 8bb9e244a2..354da6b171 100644 --- a/app/jobs/patients_clear_registration_job.rb +++ b/app/jobs/patients_clear_registration_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PatientsClearRegistrationJob < ApplicationJob +class PatientsClearRegistrationJob < ApplicationJobActiveJob queue_as :patients def perform diff --git a/app/jobs/patients_refused_consent_already_vaccinated_job.rb b/app/jobs/patients_refused_consent_already_vaccinated_job.rb index ae50cc6d32..538cbb4dd9 100644 --- a/app/jobs/patients_refused_consent_already_vaccinated_job.rb +++ b/app/jobs/patients_refused_consent_already_vaccinated_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PatientsRefusedConsentAlreadyVaccinatedJob < ApplicationJob +class PatientsRefusedConsentAlreadyVaccinatedJob < ApplicationJobActiveJob queue_as :patients def perform diff --git a/app/jobs/pds_cascading_search_job.rb b/app/jobs/pds_cascading_search_job.rb index bce0fafeae..48de7c86e1 100644 --- a/app/jobs/pds_cascading_search_job.rb +++ b/app/jobs/pds_cascading_search_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PDSCascadingSearchJob < ApplicationJob +class PDSCascadingSearchJob < ApplicationJobActiveJob include PDSThrottlingConcern queue_as :pds diff --git a/app/jobs/process_consent_form_job.rb b/app/jobs/process_consent_form_job.rb index 2c3df4027a..d9c7cebcda 100644 --- a/app/jobs/process_consent_form_job.rb +++ b/app/jobs/process_consent_form_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProcessConsentFormJob < ApplicationJob +class ProcessConsentFormJob < ApplicationJobActiveJob include PDSThrottlingConcern queue_as :consents diff --git a/app/jobs/process_import_job.rb b/app/jobs/process_import_job.rb index 33416cfcb7..a583f5d7aa 100644 --- a/app/jobs/process_import_job.rb +++ b/app/jobs/process_import_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProcessImportJob < ApplicationJob +class ProcessImportJob < ApplicationJobActiveJob include SingleConcurrencyConcern queue_as :imports diff --git a/app/jobs/process_patient_changeset_job.rb b/app/jobs/process_patient_changeset_job.rb index bcacc6f22c..6d61b0e0ec 100644 --- a/app/jobs/process_patient_changeset_job.rb +++ b/app/jobs/process_patient_changeset_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ProcessPatientChangesetJob < ApplicationJob +class ProcessPatientChangesetJob < ApplicationJobActiveJob queue_as :imports def perform(patient_changeset_id) diff --git a/app/jobs/remove_import_csv_job.rb b/app/jobs/remove_import_csv_job.rb index 7fa5308863..9f30f74f71 100644 --- a/app/jobs/remove_import_csv_job.rb +++ b/app/jobs/remove_import_csv_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class RemoveImportCSVJob < ApplicationJob +class RemoveImportCSVJob < ApplicationJobActiveJob queue_as :imports def perform diff --git a/app/jobs/reporting_api/refresh_job.rb b/app/jobs/reporting_api/refresh_job.rb index eb83804cc9..30ddc0823d 100644 --- a/app/jobs/reporting_api/refresh_job.rb +++ b/app/jobs/reporting_api/refresh_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ReportingAPI::RefreshJob < ApplicationJob +class ReportingAPI::RefreshJob < ApplicationJobActiveJob def perform ReportingAPI::Total.refresh! end diff --git a/app/jobs/review_class_import_school_move_job.rb b/app/jobs/review_class_import_school_move_job.rb index f5d2a41525..b501755f1c 100644 --- a/app/jobs/review_class_import_school_move_job.rb +++ b/app/jobs/review_class_import_school_move_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ReviewClassImportSchoolMoveJob < ApplicationJob +class ReviewClassImportSchoolMoveJob < ApplicationJobActiveJob queue_as :imports def perform(import_id) diff --git a/app/jobs/review_patient_changeset_job.rb b/app/jobs/review_patient_changeset_job.rb index cb83d365a9..05dff8dfb9 100644 --- a/app/jobs/review_patient_changeset_job.rb +++ b/app/jobs/review_patient_changeset_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class ReviewPatientChangesetJob < ApplicationJob +class ReviewPatientChangesetJob < ApplicationJobActiveJob queue_as :imports def perform(patient_changeset_id) diff --git a/app/jobs/search_vaccination_records_in_nhs_job.rb b/app/jobs/search_vaccination_records_in_nhs_job.rb index 06a64ff4ba..9dfac27086 100644 --- a/app/jobs/search_vaccination_records_in_nhs_job.rb +++ b/app/jobs/search_vaccination_records_in_nhs_job.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -class SearchVaccinationRecordsInNHSJob - include Sidekiq::Job +class SearchVaccinationRecordsInNHSJob < ApplicationJobSidekiq include ImmunisationsAPIThrottlingConcern sidekiq_options queue: :immunisations_api_search diff --git a/app/jobs/send_automated_careplus_reports_job.rb b/app/jobs/send_automated_careplus_reports_job.rb index 22e0901273..113975a0b3 100644 --- a/app/jobs/send_automated_careplus_reports_job.rb +++ b/app/jobs/send_automated_careplus_reports_job.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class SendAutomatedCareplusReportsJob - include Sidekiq::Job - +class SendAutomatedCareplusReportsJob < ApplicationJobSidekiq sidekiq_options queue: :careplus, lock: :until_executed def perform(team_id) diff --git a/app/jobs/send_automatic_school_consent_reminders_job.rb b/app/jobs/send_automatic_school_consent_reminders_job.rb index 7c57b7a5e2..5232411369 100644 --- a/app/jobs/send_automatic_school_consent_reminders_job.rb +++ b/app/jobs/send_automatic_school_consent_reminders_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SendAutomaticSchoolConsentRemindersJob < ApplicationJob +class SendAutomaticSchoolConsentRemindersJob < ApplicationJobActiveJob include SendSchoolConsentNotificationConcern queue_as :notifications diff --git a/app/jobs/send_manual_school_consent_reminders_job.rb b/app/jobs/send_manual_school_consent_reminders_job.rb index 57c82582c2..d5374f47db 100644 --- a/app/jobs/send_manual_school_consent_reminders_job.rb +++ b/app/jobs/send_manual_school_consent_reminders_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SendManualSchoolConsentRemindersJob < ApplicationJob +class SendManualSchoolConsentRemindersJob < ApplicationJobActiveJob include SendSchoolConsentNotificationConcern queue_as :notifications diff --git a/app/jobs/send_school_consent_requests_job.rb b/app/jobs/send_school_consent_requests_job.rb index 7cea02357d..c269e6d658 100644 --- a/app/jobs/send_school_consent_requests_job.rb +++ b/app/jobs/send_school_consent_requests_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SendSchoolConsentRequestsJob < ApplicationJob +class SendSchoolConsentRequestsJob < ApplicationJobActiveJob include SendSchoolConsentNotificationConcern queue_as :notifications diff --git a/app/jobs/send_school_session_reminders_job.rb b/app/jobs/send_school_session_reminders_job.rb index 7f4d0c40ea..949ece2a43 100644 --- a/app/jobs/send_school_session_reminders_job.rb +++ b/app/jobs/send_school_session_reminders_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SendSchoolSessionRemindersJob < ApplicationJob +class SendSchoolSessionRemindersJob < ApplicationJobActiveJob queue_as :notifications def perform(session) diff --git a/app/jobs/send_vaccination_confirmations_job.rb b/app/jobs/send_vaccination_confirmations_job.rb index aa11d3357c..3e71b0302f 100644 --- a/app/jobs/send_vaccination_confirmations_job.rb +++ b/app/jobs/send_vaccination_confirmations_job.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class SendVaccinationConfirmationsJob < ApplicationJob +class SendVaccinationConfirmationsJob < ApplicationJobActiveJob queue_as :notifications def perform diff --git a/app/jobs/sync_vaccination_record_to_nhs_job.rb b/app/jobs/sync_vaccination_record_to_nhs_job.rb index 5bc99f4d87..f6e415e876 100644 --- a/app/jobs/sync_vaccination_record_to_nhs_job.rb +++ b/app/jobs/sync_vaccination_record_to_nhs_job.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true -class SyncVaccinationRecordToNHSJob - include Sidekiq::Job +class SyncVaccinationRecordToNHSJob < ApplicationJobSidekiq include ImmunisationsAPIThrottlingConcern sidekiq_options queue: :immunisations_api_sync, diff --git a/app/jobs/trim_active_record_sessions_job.rb b/app/jobs/trim_active_record_sessions_job.rb index efa3ff2a23..4b6316fd9a 100644 --- a/app/jobs/trim_active_record_sessions_job.rb +++ b/app/jobs/trim_active_record_sessions_job.rb @@ -2,7 +2,7 @@ # Based off https://github.com/rails/activerecord-session_store/blob/9198a952916df925f36ac2beab247296ee5c0341/lib/tasks/database.rake#L14-L20 -class TrimActiveRecordSessionsJob < ApplicationJob +class TrimActiveRecordSessionsJob < ApplicationJobActiveJob def perform ActiveRecord::SessionStore::Session.where( "updated_at < ?", From c1a0ecfa3fef89a374bf66ac80fe5600ef70dd16 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 29 Apr 2026 16:51:49 +0100 Subject: [PATCH 72/74] Add logging to `PatientDeleter` We are seeing severe performance issues with calling this class. This logging will be the first step towards allowing us to diagnose these problems. Jira-Issue: MAV-7304 --- app/lib/patient_deleter.rb | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/app/lib/patient_deleter.rb b/app/lib/patient_deleter.rb index ecc914de1d..1bb58acf56 100644 --- a/app/lib/patient_deleter.rb +++ b/app/lib/patient_deleter.rb @@ -38,13 +38,15 @@ def call parent_ids = Parent.joins(parent_relationships: :patient).merge(@patients).ids delete_related(ParentRelationship) - Parent - .where(id: parent_ids) - .where - .missing(:parent_relationships) - .destroy_all + floating_parents = + Parent.where(id: parent_ids).where.missing(:parent_relationships) + Rails.logger.info "Deleting #{floating_parents.count} floating parents" + floating_parents.destroy_all + Rails.logger.info "Deleting #{@patients.count} patient records" @patients.each(&:destroy) + + Rails.logger.info "PatientDeleter complete" end end @@ -55,6 +57,9 @@ def self.call(...) = new(...).call private def delete_related(scope) - scope.joins(:patient).merge(@patients).delete_all + deletion_scope = scope.joins(:patient).merge(@patients) + + Rails.logger.info "Deleting #{deletion_scope.count} #{scope.name.pluralize}" + deletion_scope.delete_all end end From bb71dc348d4ca1aa8ed86b112765d803088a2631 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 29 Apr 2026 18:07:49 +0100 Subject: [PATCH 73/74] Add indexes to more patient join tables These missing indexes were causing patient deletion queries to be extremely slow. Deleting a patient used to trigger sequential scans on each of these tables, in order to enforce the FK constraint on `patient_id`. Testing in data replication shows that patient delete queries reduce from 100s of millisecs to 1s of ms; 2 orders of magnitude increase. Jira-Issue: MAV-7304 --- app/models/class_imports_patient.rb | 1 + app/models/cohort_imports_patient.rb | 1 + ..._patient_id_indexes_to_import_join_tables.rb | 17 +++++++++++++++++ db/schema.rb | 5 ++++- 4 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260429175616_add_patient_id_indexes_to_import_join_tables.rb diff --git a/app/models/class_imports_patient.rb b/app/models/class_imports_patient.rb index e9c2d9fecf..32473636bd 100644 --- a/app/models/class_imports_patient.rb +++ b/app/models/class_imports_patient.rb @@ -10,6 +10,7 @@ # Indexes # # index_class_imports_patients_on_class_import_id_and_patient_id (class_import_id,patient_id) UNIQUE +# index_class_imports_patients_on_patient_id_and_class_import_id (patient_id,class_import_id) UNIQUE # # Foreign Keys # diff --git a/app/models/cohort_imports_patient.rb b/app/models/cohort_imports_patient.rb index 0d217516ab..6e894deca9 100644 --- a/app/models/cohort_imports_patient.rb +++ b/app/models/cohort_imports_patient.rb @@ -10,6 +10,7 @@ # Indexes # # idx_on_cohort_import_id_patient_id_7864d1a8b0 (cohort_import_id,patient_id) UNIQUE +# idx_on_patient_id_cohort_import_id_5e41b290b4 (patient_id,cohort_import_id) UNIQUE # # Foreign Keys # diff --git a/db/migrate/20260429175616_add_patient_id_indexes_to_import_join_tables.rb b/db/migrate/20260429175616_add_patient_id_indexes_to_import_join_tables.rb new file mode 100644 index 0000000000..103c79b7f6 --- /dev/null +++ b/db/migrate/20260429175616_add_patient_id_indexes_to_import_join_tables.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddPatientIdIndexesToImportJoinTables < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + INDEXES = { + class_imports_patients: %i[patient_id class_import_id], + cohort_imports_patients: %i[patient_id cohort_import_id], + immunisation_imports_patients: %i[patient_id immunisation_import_id] + }.freeze + + def change + INDEXES.each do |table_name, columns| + add_index table_name, columns, unique: true, algorithm: :concurrently + end + end +end diff --git a/db/schema.rb b/db/schema.rb index bfedd4ecdc..b648a5c2fb 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_04_28_080729) do +ActiveRecord::Schema[8.1].define(version: 2026_04_29_175616) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -175,6 +175,7 @@ t.bigint "class_import_id", null: false t.bigint "patient_id", null: false t.index ["class_import_id", "patient_id"], name: "index_class_imports_patients_on_class_import_id_and_patient_id", unique: true + t.index ["patient_id", "class_import_id"], name: "index_class_imports_patients_on_patient_id_and_class_import_id", unique: true end create_table "clinic_notifications", force: :cascade do |t| @@ -233,6 +234,7 @@ t.bigint "cohort_import_id", null: false t.bigint "patient_id", null: false t.index ["cohort_import_id", "patient_id"], name: "idx_on_cohort_import_id_patient_id_7864d1a8b0", unique: true + t.index ["patient_id", "cohort_import_id"], name: "idx_on_patient_id_cohort_import_id_5e41b290b4", unique: true end create_table "consent_form_programmes", force: :cascade do |t| @@ -445,6 +447,7 @@ t.bigint "immunisation_import_id", null: false t.bigint "patient_id", null: false t.index ["immunisation_import_id", "patient_id"], name: "idx_on_immunisation_import_id_patient_id_6dc58d875d", unique: true + t.index ["patient_id", "immunisation_import_id"], name: "idx_on_patient_id_immunisation_import_id_3f154728df", unique: true end create_table "immunisation_imports_sessions", id: false, force: :cascade do |t| From dbdc3b0a0b7da65a663091d5ca33362d17148d49 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 29 Apr 2026 19:01:54 +0100 Subject: [PATCH 74/74] Use batching in `PatientDeleter` and reset CLI For a large number of patients (>15,000), the `@patients.each(&:destroy)` is causing memory issues. Changing this to `find_each` should mitigate this issue. This issue was also present when destroying `VaccinationRecord`s in `bin/mavis teams reset-national-reporting` Jira-Issue: MAV-7304 --- app/lib/mavis_cli/teams/reset_national_reporting.rb | 6 +++--- app/lib/patient_deleter.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/lib/mavis_cli/teams/reset_national_reporting.rb b/app/lib/mavis_cli/teams/reset_national_reporting.rb index b27cd3239c..9f4186311b 100644 --- a/app/lib/mavis_cli/teams/reset_national_reporting.rb +++ b/app/lib/mavis_cli/teams/reset_national_reporting.rb @@ -110,7 +110,7 @@ def reset_team(team) end puts "Destroying #{not_synced_vaccination_records.count} vaccination records..." - not_synced_vaccination_records.destroy_all + not_synced_vaccination_records.find_each(&:destroy) puts "Refreshing immunisations imports..." if immunisation_imports.joins(:vaccination_records).any? @@ -128,7 +128,7 @@ def reset_team(team) end puts "Destroying #{immunisation_imports.count} immunisation imports..." - immunisation_imports.destroy_all + immunisation_imports.find_each(&:destroy) archive_reasons = ArchiveReason.where( @@ -136,7 +136,7 @@ def reset_team(team) team: ) puts "Destroying #{archive_reasons.count} archive reasons..." - archive_reasons.destroy_all + archive_reasons.find_each(&:destroy) puts "Updating patient-team relationships..." PatientTeamUpdater.call( diff --git a/app/lib/patient_deleter.rb b/app/lib/patient_deleter.rb index 1bb58acf56..b66df1cab2 100644 --- a/app/lib/patient_deleter.rb +++ b/app/lib/patient_deleter.rb @@ -44,7 +44,7 @@ def call floating_parents.destroy_all Rails.logger.info "Deleting #{@patients.count} patient records" - @patients.each(&:destroy) + @patients.find_each(&:destroy) Rails.logger.info "PatientDeleter complete" end