diff --git a/app/controllers/admin/deduplicate_patients_controller.rb b/app/controllers/admin/deduplicate_patients_controller.rb index 0cbd71d60e..f6bf01abe8 100644 --- a/app/controllers/admin/deduplicate_patients_controller.rb +++ b/app/controllers/admin/deduplicate_patients_controller.rb @@ -1,18 +1,28 @@ class Admin::DeduplicatePatientsController < AdminController skip_before_action :verify_authenticity_token + before_action :set_filter_options, only: [:show] DUPLICATE_LIMIT = 250 def show facilities = current_admin.accessible_facilities(:manage) authorize { facilities.any? } + # Apply facility filter if selected + filtered_facilities = if @selected_facility.present? + facilities.where(id: @selected_facility.id) + elsif @selected_district.present? + facilities.where(id: @selected_district.facilities) + else + facilities + end + # Scoping by facilities is costly for users who have a lot of facilities - duplicate_patient_ids = if current_admin.accessible_organizations(:manage).any? + duplicate_patient_ids = if current_admin.accessible_organizations(:manage).any? && !@selected_district.present? && !@selected_facility.present? PatientDeduplication::Strategies.identifier_excluding_full_name_match(limit: DUPLICATE_LIMIT) else PatientDeduplication::Strategies.identifier_excluding_full_name_match_for_facilities( limit: DUPLICATE_LIMIT, - facilities: facilities + facilities: filtered_facilities ) end @@ -44,4 +54,38 @@ def can_admin_deduplicate_patients?(patients) .where(id: patients.pluck(:assigned_facility_id)) .any? end + + private + + def set_filter_options + @accessible_facilities = current_admin.accessible_facilities(:manage) + populate_districts + set_selected_district + populate_facilities + set_selected_facility + end + + def populate_districts + @districts = Region.district_regions + .joins("INNER JOIN regions facility_region ON regions.path @> facility_region.path") + .where("facility_region.source_id" => @accessible_facilities.map(&:id)) + .distinct(:slug) + .order(:name) + end + + def set_selected_district + @selected_district = if params[:district_slug].present? + @districts.find_by(slug: params[:district_slug]) + elsif @districts.present? + @districts.first + end + end + + def populate_facilities + @facilities = @accessible_facilities.where(id: @selected_district&.facilities).order(:name) + end + + def set_selected_facility + @selected_facility = @facilities.find_by(id: params[:facility_id]) if params[:facility_id].present? + end end diff --git a/app/controllers/api/v4/patient_scores_controller.rb b/app/controllers/api/v4/patient_scores_controller.rb new file mode 100644 index 0000000000..e2c50c9b96 --- /dev/null +++ b/app/controllers/api/v4/patient_scores_controller.rb @@ -0,0 +1,31 @@ +class Api::V4::PatientScoresController < Api::V4::SyncController + def sync_to_user + __sync_to_user__("patient_scores") + end + + def current_facility_records + @current_facility_records ||= + PatientScore + .for_sync + .where(patient: current_facility.prioritized_patients.select(:id)) + .updated_on_server_since(current_facility_processed_since, limit) + end + + def other_facility_records + other_facilities_limit = limit - current_facility_records.size + @other_facility_records ||= + PatientScore + .for_sync + .where(patient_id: current_sync_region + .syncable_patients + .where.not(registration_facility: current_facility) + .select(:id)) + .updated_on_server_since(other_facilities_processed_since, other_facilities_limit) + end + + private + + def transform_to_response(patient_score) + Api::V4::PatientScoreTransformer.to_response(patient_score) + end +end diff --git a/app/models/patient_score.rb b/app/models/patient_score.rb new file mode 100644 index 0000000000..e43576bc00 --- /dev/null +++ b/app/models/patient_score.rb @@ -0,0 +1,13 @@ +class PatientScore < ApplicationRecord + include Mergeable + include Discard::Model + + belongs_to :patient, optional: true + + validates :device_created_at, presence: true + validates :device_updated_at, presence: true + validates :score_type, presence: true + validates :score_value, presence: true, numericality: true + + scope :for_sync, -> { with_discarded } +end diff --git a/app/schema/api/v4/models.rb b/app/schema/api/v4/models.rb index caac4c712c..7b70e2418e 100644 --- a/app/schema/api/v4/models.rb +++ b/app/schema/api/v4/models.rb @@ -117,6 +117,20 @@ def patient_attribute required: %w[id patient_id height weight created_at updated_at]} end + def patient_score + {type: :object, + properties: { + id: {"$ref" => "#/definitions/uuid"}, + patient_id: {"$ref" => "#/definitions/uuid"}, + score_type: {"$ref" => "#/definitions/non_empty_string"}, + score_value: {type: :number}, + deleted_at: {"$ref" => "#/definitions/nullable_timestamp"}, + created_at: {"$ref" => "#/definitions/timestamp"}, + updated_at: {"$ref" => "#/definitions/timestamp"} + }, + required: %w[id patient_id score_type score_value created_at updated_at]} + end + def patient_phone_number { type: :object, @@ -458,6 +472,8 @@ def definitions patient: patient, patient_attribute: patient_attribute, patient_attributes: Api::CommonDefinitions.array_of("patient_attribute"), + patient_score: patient_score, + patient_scores: Api::CommonDefinitions.array_of("patient_score"), patient_business_identifier: Api::V3::Models.patient_business_identifier, patient_business_identifiers: Api::CommonDefinitions.array_of("patient_business_identifier"), phone_number: Api::V3::Models.phone_number, diff --git a/app/transformers/api/v4/patient_score_transformer.rb b/app/transformers/api/v4/patient_score_transformer.rb new file mode 100644 index 0000000000..46e3cf8612 --- /dev/null +++ b/app/transformers/api/v4/patient_score_transformer.rb @@ -0,0 +1,22 @@ +class Api::V4::PatientScoreTransformer < Api::V4::Transformer + class << self + def to_response(payload) + current_time = Time.current.iso8601 + super(payload) + .merge({ + "score_type" => payload["score_type"], + "score_value" => payload["score_value"].to_f, + "created_at" => current_time, + "updated_at" => current_time + }) + end + + def from_request(payload) + super(payload) + .merge({ + "score_type" => payload["score_type"], + "score_value" => payload["score_value"].to_f + }) + end + end +end diff --git a/app/views/admin/deduplicate_patients/show.html.erb b/app/views/admin/deduplicate_patients/show.html.erb index 5532fb1bd9..529beeb9b0 100644 --- a/app/views/admin/deduplicate_patients/show.html.erb +++ b/app/views/admin/deduplicate_patients/show.html.erb @@ -1,4 +1,36 @@

Merge duplicate patients

+ +<%= bootstrap_form_with(url: admin_deduplication_path, method: :get, layout: :horizontal, class: "mb-4") do |form| %> + <% html_select_options = { onchange: "this.form.submit();" } + searchable_select_options = html_select_options.merge(class: "selectpicker", data: {live_search: true}) %> +
+
+ <%= form.select :district_slug, + @districts.order(:name).map { |district| [district.name, district.slug] }, + { + hide_label: true, + selected: @selected_district&.slug, + wrapper: false + }, + searchable_select_options + %> +
+
+ <%= form.select :facility_id, + @facilities.order(:name).map { |facility| [facility.label_with_district, facility.id] }, + { + hide_label: true, + include_blank: "All facilities", + selected: @selected_facility&.id, + wrapper: false + }, + searchable_select_options + %> +
+
+ <%= form.submit "Filter", style: "display: none" %> +<% end %> +

<% if @duplicate_count == Admin::DeduplicatePatientsController::DUPLICATE_LIMIT %> diff --git a/config/routes.rb b/config/routes.rb index d48555010f..1fe42b775a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -71,6 +71,10 @@ get "sync", to: "cvd_risks#sync_to_user" post "sync", to: "cvd_risks#sync_from_user" end + + scope :patient_scores do + get "sync", to: "patient_scores#sync_to_user" + end end namespace :webview do diff --git a/db/migrate/20260209112204_create_patient_scores.rb b/db/migrate/20260209112204_create_patient_scores.rb new file mode 100644 index 0000000000..d6b687bf9b --- /dev/null +++ b/db/migrate/20260209112204_create_patient_scores.rb @@ -0,0 +1,24 @@ +class CreatePatientScores < ActiveRecord::Migration[6.1] + def change + unless table_exists?(:patient_scores) + create_table :patient_scores, id: :uuid do |t| + t.references :patient, null: false, foreign_key: true, type: :uuid + t.string :score_type, null: false, limit: 100 + t.decimal :score_value, precision: 5, scale: 2, null: false + t.datetime :device_created_at, null: false + t.datetime :device_updated_at, null: false + t.datetime :deleted_at + + t.timestamps + end + end + + unless index_exists?(:patient_scores, [:patient_id, :score_type]) + add_index :patient_scores, [:patient_id, :score_type] + end + + unless index_exists?(:patient_scores, :updated_at) + add_index :patient_scores, :updated_at + end + end +end diff --git a/db/structure.sql b/db/structure.sql index 5ff0b863cd..64151dcfb7 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2683,6 +2683,23 @@ CREATE TABLE public.patient_phone_numbers ( ); +-- +-- Name: patient_scores; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.patient_scores ( + id uuid NOT NULL, + patient_id uuid NOT NULL, + score_type character varying(100) NOT NULL, + score_value numeric(5,2) NOT NULL, + device_created_at timestamp without time zone NOT NULL, + device_updated_at timestamp without time zone NOT NULL, + deleted_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + -- -- Name: prescription_drugs; Type: TABLE; Schema: public; Owner: - -- @@ -7398,6 +7415,14 @@ ALTER TABLE ONLY public.patient_phone_numbers ADD CONSTRAINT patient_phone_numbers_pkey PRIMARY KEY (id); +-- +-- Name: patient_scores patient_scores_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.patient_scores + ADD CONSTRAINT patient_scores_pkey PRIMARY KEY (id); + + -- -- Name: patients patients_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -8501,6 +8526,20 @@ CREATE INDEX index_patient_phone_numbers_on_dnd_status ON public.patient_phone_n CREATE INDEX index_patient_phone_numbers_on_patient_id ON public.patient_phone_numbers USING btree (patient_id); +-- +-- Name: index_patient_scores_on_patient_id_and_score_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_patient_scores_on_patient_id_and_score_type ON public.patient_scores USING btree (patient_id, score_type); + + +-- +-- Name: index_patient_scores_on_updated_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_patient_scores_on_updated_at ON public.patient_scores USING btree (updated_at); + + -- -- Name: index_patient_registrations_per_day_per_facilities; Type: INDEX; Schema: public; Owner: - -- @@ -9121,6 +9160,14 @@ ALTER TABLE ONLY public.patient_phone_numbers ADD CONSTRAINT fk_rails_0145dd0b05 FOREIGN KEY (patient_id) REFERENCES public.patients(id); +-- +-- Name: patient_scores fk_rails_0209112204; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.patient_scores + ADD CONSTRAINT fk_rails_0209112204 FOREIGN KEY (patient_id) REFERENCES public.patients(id); + + -- -- Name: facility_groups fk_rails_0ba9e6af98; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -9674,6 +9721,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20251215113615'), ('20251219061210'), ('20260128094448'), +('20260209112204'), ('20260212195326'), ('20260205110957'), ('20260224063659'), diff --git a/spec/controllers/api/v4/patient_scores_controller_spec.rb b/spec/controllers/api/v4/patient_scores_controller_spec.rb new file mode 100644 index 0000000000..c153ec671e --- /dev/null +++ b/spec/controllers/api/v4/patient_scores_controller_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +describe Api::V4::PatientScoresController, type: :controller do + let(:request_user) { create(:user) } + let(:request_facility_group) { request_user.facility.facility_group } + let(:request_facility) { create(:facility, facility_group: request_facility_group) } + let(:model) { PatientScore } + + def create_record(options = {}) + facility = create(:facility, facility_group: request_facility_group) + patient = create(:patient, registration_facility: facility) + create(:patient_score, options.merge(patient: patient)) + end + + def create_record_list(n, options = {}) + facility = create(:facility, facility_group_id: request_facility_group.id) + patient = create(:patient, registration_facility_id: facility.id) + create_list(:patient_score, n, options.merge(patient: patient)) + end + + it_behaves_like "a sync controller that authenticates user requests: sync_to_user" + it_behaves_like "a sync controller that audits the data access: sync_to_user" + + describe "GET sync: send data from server to device;" do + it_behaves_like "a working V3 sync controller sending records" + end +end diff --git a/spec/factories/patient_scores.rb b/spec/factories/patient_scores.rb new file mode 100644 index 0000000000..7e82fb102f --- /dev/null +++ b/spec/factories/patient_scores.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :patient_score do + id { SecureRandom.uuid } + patient + score_type { "risk_score" } + score_value { 75.50 } + device_created_at { Time.current } + device_updated_at { Time.current } + end +end