Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9ceb635
SIMPLEBACK-34: Add sync API for patient scores (read-only)
sshinde-rtsl Feb 9, 2026
c449801
Merge branch 'master' into SIMPLEBACK-34
sshinde-rtsl Feb 11, 2026
815d52b
Merge branch 'master' into SIMPLEBACK-34
sshinde-rtsl Mar 9, 2026
19c7acf
Fixed.The issue was that db/structure.sql had a premature semicolon a…
sshinde-rtsl Mar 9, 2026
b1608f8
Merge branch 'master' into SIMPLEBACK-34
sshinde-rtsl Mar 10, 2026
4d43f01
Add district/facility filter to patient merge screen
sshinde-rtsl Mar 17, 2026
99338d3
Merge branch 'master' into SIMPLEBACK-34
sshinde-rtsl Mar 17, 2026
cac5c00
Add sync methods and timestamps to patient scores API
sshinde-rtsl Mar 19, 2026
f25a949
Merge branch 'master' into SIMPLEBACK-34
sshinde-rtsl Apr 13, 2026
3ef858a
fix: Use actual record timestamps in PatientScoreTransformer
sshinde-rtsl Apr 13, 2026
daa7ab6
fix: Remove duplicate migration entry in structure.sql
sshinde-rtsl Apr 13, 2026
8489fba
fix: Spread clustered PatientScore updated_at timestamps
sshinde-rtsl Apr 14, 2026
e1ab85d
Merge branch 'master' into SIMPLEBACK-34
sshinde-rtsl Apr 14, 2026
9d36154
Implement keyset pagination for PatientScore sync
sshinde-rtsl Apr 14, 2026
7ca6128
fix: Eager load PatientScore pagination results to fix cursor tracking
sshinde-rtsl Apr 14, 2026
19be76e
fix: Parse process_token timestamps in UTC for keyset pagination Time…
sshinde-rtsl Apr 14, 2026
b958b09
refactor: Use next_page token for PatientScore sync, drop other-facil…
sshinde-rtsl Apr 14, 2026
b364838
fix: Advance next_page on every non-empty page, reset only on empty
sshinde-rtsl Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/controllers/api/v4/patient_scores_controller.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions app/models/patient_score.rb
Original file line number Diff line number Diff line change
@@ -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
16 changes: 16 additions & 0 deletions app/schema/api/v4/models.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
19 changes: 19 additions & 0 deletions app/transformers/api/v4/patient_score_transformer.rb
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions db/data/20260414103256_spread_patient_scores_updated_at.rb
Original file line number Diff line number Diff line change
@@ -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
24 changes: 24 additions & 0 deletions db/migrate/20260209112204_create_patient_scores.rb
Original file line number Diff line number Diff line change
@@ -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
104 changes: 58 additions & 46 deletions db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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
--
Expand Down Expand Up @@ -9713,6 +9724,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20260127150000'),
('20260128094448'),
('20260205110957'),
('20260209112204'),
('20260212195326'),
('20260224063659'),
('20260316093605'),
Expand Down
Loading