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..13e6432e0b --- /dev/null +++ b/app/controllers/api/v4/patient_scores_controller.rb @@ -0,0 +1,40 @@ +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)) + .order(:updated_at, :id) + .limit(limit) + .offset((current_page - 1) * limit) + .to_a + end + + def other_facility_records + [] + end + + private + + def transform_to_response(patient_score) + Api::V4::PatientScoreTransformer.to_response(patient_score) + end + + def current_page + page = process_token[:next_page].to_i + page < 1 ? 1 : page + end + + def response_process_token + { + current_facility_id: current_facility.id, + next_page: current_facility_records.empty? ? 1 : current_page + 1, + resync_token: resync_token, + sync_region_id: current_sync_region.id + } + 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..93dc2c4711 --- /dev/null +++ b/app/transformers/api/v4/patient_score_transformer.rb @@ -0,0 +1,19 @@ +class Api::V4::PatientScoreTransformer < Api::V4::Transformer + class << self + def to_response(payload) + super(payload) + .merge({ + "score_type" => payload["score_type"], + "score_value" => payload["score_value"].to_f + }) + 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/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/data/20260414103256_spread_patient_scores_updated_at.rb b/db/data/20260414103256_spread_patient_scores_updated_at.rb new file mode 100644 index 0000000000..f299a8b6bd --- /dev/null +++ b/db/data/20260414103256_spread_patient_scores_updated_at.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class SpreadPatientScoresUpdatedAt < ActiveRecord::Migration[6.1] + def up + # Find the clustered timestamp that's causing sync pagination issues + # This happens when bulk inserts share the same updated_at + clustered_timestamps = PatientScore.group(:updated_at) + .having("count(*) > 1000") + .count + .keys + + return if clustered_timestamps.empty? + + clustered_timestamps.each do |clustered_timestamp| + Rails.logger.info "Spreading updated_at for PatientScores with timestamp: #{clustered_timestamp}" + + # First, check if device_updated_at is well-distributed + device_updated_distribution = PatientScore + .where(updated_at: clustered_timestamp) + .group(:device_updated_at) + .count + .sort_by { |_, n| -n } + .first(5) + + max_device_cluster = device_updated_distribution.first&.last || 0 + + if max_device_cluster < 1000 + # device_updated_at is well-distributed, use it + Rails.logger.info "Using device_updated_at (max cluster: #{max_device_cluster})" + PatientScore + .where(updated_at: clustered_timestamp) + .update_all("updated_at = device_updated_at") + else + # device_updated_at is also clustered, spread by id with millisecond offsets + Rails.logger.info "Spreading by id with millisecond offsets (device_updated_at max cluster: #{max_device_cluster})" + ActiveRecord::Base.connection.execute(<<-SQL.squish) + UPDATE patient_scores ps + SET updated_at = '#{clustered_timestamp}'::timestamp + + (sub.row_num * interval '1 millisecond') + FROM ( + SELECT id, row_number() OVER (ORDER BY id) AS row_num + FROM patient_scores + WHERE updated_at = '#{clustered_timestamp}' + ) sub + WHERE ps.id = sub.id + SQL + end + end + end + + def down + # This migration cannot be reversed as we don't track original values + raise ActiveRecord::IrreversibleMigration + end +end 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 1033552ae6..7c708197e8 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -748,9 +748,7 @@ CREATE TABLE IF NOT EXISTS simple_reporting.reporting_patient_prescriptions ( hypertension_drug_changed boolean, diabetes_drug_changed boolean, other_drug_changed boolean, - prescribed_statins boolean, - latest_cvd_risk_score_lower_range integer, - latest_cvd_risk_score_upper_range integer + prescribed_statins boolean ) PARTITION BY LIST (month_date); @@ -865,10 +863,7 @@ CREATE OR REPLACE FUNCTION simple_reporting.reporting_patient_prescriptions_tabl ) elem WHERE elem->>'drug_name' ILIKE '%statin%' ) - ) AS prescribed_statins, - - cvd.latest_cvd_risk_score_lower_range, - cvd.latest_cvd_risk_score_upper_range + ) AS prescribed_statins FROM simple_reporting.reporting_patient_states rps LEFT JOIN reporting_facilities assigned_facility ON rps.assigned_facility_id = assigned_facility.facility_id @@ -945,27 +940,6 @@ CREATE OR REPLACE FUNCTION simple_reporting.reporting_patient_prescriptions_tabl ) ) ) prev ON TRUE - - LEFT JOIN LATERAL ( - SELECT - split_part(cr.risk_score,'-',1)::int AS latest_cvd_risk_score_lower_range, - - COALESCE( - NULLIF(split_part(cr.risk_score,'-',2),''), - split_part(cr.risk_score,'-',1) - )::int AS latest_cvd_risk_score_upper_range - - FROM cvd_risks cr - WHERE cr.patient_id = rps.patient_id - AND cr.deleted_at IS NULL - AND date_trunc('month', - timezone(current_setting('TIMEZONE'), - timezone('UTC', cr.device_updated_at)) - ) < (rps.month_date + interval '1 month') - - ORDER BY cr.device_updated_at DESC - LIMIT 1 - ) cvd ON TRUE WHERE rps.month_date = $1 AND rps.htn_care_state <> 'dead'; END; @@ -2709,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: - -- @@ -7424,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: - -- @@ -8527,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: - -- @@ -9109,24 +9122,6 @@ CREATE INDEX patient_prescriptions_assigned_organization_region_id ON ONLY simpl CREATE INDEX index_patient_prescriptions_patient_id ON ONLY simple_reporting.reporting_patient_prescriptions USING btree (patient_id); --- --- Name: idx_rpp_month_patient; Type: INDEX; Schema: simple_reporting; Owner: - --- - -CREATE INDEX idx_rpp_month_patient ON simple_reporting.reporting_patient_prescriptions (month_date, patient_id); - --- --- Name: idx_rpp_latest_cvd_score_lower_range; Type: INDEX; Schema: simple_reporting; Owner: - --- - -CREATE INDEX idx_rpp_latest_cvd_score_lower_range ON simple_reporting.reporting_patient_prescriptions (latest_cvd_risk_score_lower_range); - --- --- Name: idx_rpp_latest_cvd_score_upper_range; Type: INDEX; Schema: simple_reporting; Owner: - --- - -CREATE INDEX idx_rpp_latest_cvd_score_upper_range ON simple_reporting.reporting_patient_prescriptions (latest_cvd_risk_score_upper_range); - -- -- Name: index_fs_block; Type: INDEX; Schema: simple_reporting; Owner: - -- @@ -9165,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: - -- @@ -9509,6 +9512,14 @@ ALTER TABLE ONLY public.dr_rai_targets ADD CONSTRAINT fk_rails_f0398a9ae0 FOREIGN KEY (dr_rai_indicators_id) REFERENCES public.dr_rai_indicators(id); +-- +-- Name: patient_attributes fk_rails_fc46ae3757; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.patient_attributes + ADD CONSTRAINT fk_rails_fc46ae3757 FOREIGN KEY (patient_id) REFERENCES public.patients(id); + + -- -- PostgreSQL database dump complete -- @@ -9713,6 +9724,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20260127150000'), ('20260128094448'), ('20260205110957'), +('20260209112204'), ('20260212195326'), ('20260224063659'), ('20260316093605'), 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..ee3e216fb5 --- /dev/null +++ b/spec/controllers/api/v4/patient_scores_controller_spec.rb @@ -0,0 +1,107 @@ +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 = {}) + patient = create(:patient, registration_facility: request_facility) + create(:patient_score, options.merge(patient: patient)) + end + + def create_record_list(n, options = {}) + patient = create(:patient, registration_facility: request_facility) + 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 + before { set_authentication_headers } + + it "returns only current facility patient scores" do + expected = create_record_list(3) + other_patient = create(:patient, registration_facility: create(:facility, facility_group: request_facility_group)) + create(:patient_score, patient: other_patient) + + get :sync_to_user + + body = JSON(response.body) + expect(body["patient_scores"].map { |r| r["id"] }.to_set) + .to eq(expected.map(&:id).to_set) + end + + it "paginates via next_page token across multiple requests with a shared updated_at" do + shared_ts = 5.minutes.ago + records = create_record_list(5, updated_at: shared_ts) + expected_ids = records.map(&:id).to_set + + received_ids = Set.new + process_token = nil + + 4.times do + reset_controller + set_authentication_headers + get :sync_to_user, params: {limit: 2, process_token: process_token}.compact + body = JSON(response.body) + body["patient_scores"].each { |r| received_ids << r["id"] } + process_token = body["process_token"] + break if body["patient_scores"].empty? + end + + expect(received_ids).to eq(expected_ids) + end + + it "advances next_page on every non-empty page and resets to 1 only on an empty page" do + create_record_list(5, updated_at: 5.minutes.ago) + + get :sync_to_user, params: {limit: 2} + body1 = JSON(response.body) + expect(body1["patient_scores"].size).to eq(2) + expect(parse_process_token(body1)[:next_page]).to eq(2) + + reset_controller + set_authentication_headers + get :sync_to_user, params: {limit: 2, process_token: body1["process_token"]} + body2 = JSON(response.body) + expect(body2["patient_scores"].size).to eq(2) + expect(parse_process_token(body2)[:next_page]).to eq(3) + + reset_controller + set_authentication_headers + get :sync_to_user, params: {limit: 2, process_token: body2["process_token"]} + body3 = JSON(response.body) + expect(body3["patient_scores"].size).to eq(1) + expect(parse_process_token(body3)[:next_page]).to eq(4) + + reset_controller + set_authentication_headers + get :sync_to_user, params: {limit: 2, process_token: body3["process_token"]} + body4 = JSON(response.body) + expect(body4["patient_scores"]).to eq([]) + expect(parse_process_token(body4)[:next_page]).to eq(1) + end + + it "returns an empty list and next_page=1 when there is nothing to sync" do + get :sync_to_user + + body = JSON(response.body) + token = parse_process_token(body) + expect(body["patient_scores"]).to eq([]) + expect(token[:next_page]).to eq(1) + end + + it "returns discarded records" do + records = create_record_list(3, updated_at: 5.minutes.ago) + records.first.patient.discard_data(reason: nil) + + get :sync_to_user + + body = JSON(response.body) + expect(body["patient_scores"].size).to eq(3) + end + 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