diff --git a/app/controllers/api/v4/legacy_data_dumps_controller.rb b/app/controllers/api/v4/legacy_data_dumps_controller.rb new file mode 100644 index 0000000000..a2ef68c4e0 --- /dev/null +++ b/app/controllers/api/v4/legacy_data_dumps_controller.rb @@ -0,0 +1,56 @@ +class Api::V4::LegacyDataDumpsController < APIController + def create + legacy_dump = LegacyMobileDataDump.new(legacy_dump_params) + + if legacy_dump.save + log_success(legacy_dump) + + render json: {id: legacy_dump.id, status: "ok"}, status: :ok + else + log_failure(legacy_dump) + + render json: {errors: legacy_dump.errors.full_messages}, status: :unprocessable_entity + end + end + + private + + def legacy_dump_params + { + raw_payload: raw_payload, + dump_date: Time.current.utc, + user: current_user, + mobile_version: mobile_version + } + end + + def raw_payload + { + "patients" => params.require(:patients) + } + end + + def mobile_version + request.headers["HTTP_X_APP_VERSION"] + end + + def log_success(legacy_dump) + Rails.logger.info( + msg: "legacy_data_dump_created", + legacy_dump_id: legacy_dump.id, + user_id: legacy_dump.user_id, + facility_id: current_facility.id, + mobile_version: legacy_dump.mobile_version, + payload_keys: legacy_dump.raw_payload.keys + ) + end + + def log_failure(legacy_dump) + Rails.logger.warn( + msg: "legacy_data_dump_failed", + user_id: legacy_dump.user_id, + facility_id: current_facility&.id, + errors: legacy_dump.errors.full_messages + ) + end +end diff --git a/app/models/legacy_mobile_data_dump.rb b/app/models/legacy_mobile_data_dump.rb new file mode 100644 index 0000000000..c6b247d97e --- /dev/null +++ b/app/models/legacy_mobile_data_dump.rb @@ -0,0 +1,7 @@ +class LegacyMobileDataDump < ActiveRecord::Base + belongs_to :user + + validates :user, presence: true + validates :raw_payload, presence: true + validates :dump_date, presence: true +end diff --git a/config/routes.rb b/config/routes.rb index 86a5e0e938..d48555010f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -128,6 +128,8 @@ namespace :v4, path: "v4" do put "import", to: "imports#import" + resources :legacy_data_dumps, only: [:create] + scope :blood_sugars do get "sync", to: "blood_sugars#sync_to_user" post "sync", to: "blood_sugars#sync_from_user" diff --git a/db/migrate/20260105123000_create_legacy_mobile_data_dumps.rb b/db/migrate/20260105123000_create_legacy_mobile_data_dumps.rb new file mode 100644 index 0000000000..67b613f143 --- /dev/null +++ b/db/migrate/20260105123000_create_legacy_mobile_data_dumps.rb @@ -0,0 +1,14 @@ +class CreateLegacyMobileDataDumps < ActiveRecord::Migration[6.1] + def change + create_table :legacy_mobile_data_dumps, id: :uuid do |t| + t.jsonb :raw_payload, null: false + t.datetime :dump_date, null: false + t.references :user, type: :uuid, foreign_key: true, null: false + t.string :mobile_version + + t.timestamps + end + + add_index :legacy_mobile_data_dumps, :dump_date + end +end diff --git a/db/structure.sql b/db/structure.sql index 0ff07a878e..2025d0451e 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -613,6 +613,21 @@ CREATE TABLE public.facilities ( ); +-- +-- Name: legacy_mobile_data_dumps; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.legacy_mobile_data_dumps ( + id uuid DEFAULT public.gen_random_uuid() NOT NULL, + raw_payload jsonb NOT NULL, + dump_date timestamp without time zone NOT NULL, + user_id uuid NOT NULL, + mobile_version character varying, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL +); + + -- -- Name: medical_histories; Type: TABLE; Schema: public; Owner: - -- @@ -6297,6 +6312,14 @@ ALTER TABLE ONLY public.facilities ADD CONSTRAINT facilities_pkey PRIMARY KEY (id); +-- +-- Name: legacy_mobile_data_dumps legacy_mobile_data_dumps_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.legacy_mobile_data_dumps + ADD CONSTRAINT legacy_mobile_data_dumps_pkey PRIMARY KEY (id); + + -- -- Name: facility_business_identifiers facility_business_identifiers_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -7143,6 +7166,20 @@ CREATE UNIQUE INDEX index_facilities_on_slug ON public.facilities USING btree (s CREATE INDEX index_facilities_on_updated_at ON public.facilities USING btree (updated_at); +-- +-- Name: index_legacy_mobile_data_dumps_on_dump_date; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_legacy_mobile_data_dumps_on_dump_date ON public.legacy_mobile_data_dumps USING btree (dump_date); + + +-- +-- Name: index_legacy_mobile_data_dumps_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_legacy_mobile_data_dumps_on_user_id ON public.legacy_mobile_data_dumps USING btree (user_id); + + -- -- Name: index_facilities_teleconsult_mos_on_facility_id_and_user_id; Type: INDEX; Schema: public; Owner: - -- @@ -8344,6 +8381,14 @@ ALTER TABLE ONLY public.facilities ADD CONSTRAINT fk_rails_c44117c78f FOREIGN KEY (facility_group_id) REFERENCES public.facility_groups(id); +-- +-- Name: legacy_mobile_data_dumps fk_rails_a1b2c3d4e5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.legacy_mobile_data_dumps + ADD CONSTRAINT fk_rails_a1b2c3d4e5 FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: dr_rai_action_plans fk_rails_c6db95d644; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -8597,5 +8642,5 @@ INSERT INTO "schema_migrations" (version) VALUES ('20250924102156'), ('20250925094123'), ('20251125090819'), -('20251211073126'); - +('20251211073126'), +('20260105123000'); diff --git a/spec/requests/api/v4/legacy_data_dumps_spec.rb b/spec/requests/api/v4/legacy_data_dumps_spec.rb new file mode 100644 index 0000000000..f10959e82f --- /dev/null +++ b/spec/requests/api/v4/legacy_data_dumps_spec.rb @@ -0,0 +1,161 @@ +require "rails_helper" + +RSpec.describe "Legacy Data Dumps", type: :request do + let(:request_user) { FactoryBot.create(:user) } + let(:request_facility) { request_user.facility } + + let(:headers) do + { + "HTTP_X_USER_ID" => request_user.id, + "HTTP_X_FACILITY_ID" => request_facility.id, + "HTTP_AUTHORIZATION" => "Bearer #{request_user.access_token}", + "HTTP_X_APP_VERSION" => "2024.1.0", + "CONTENT_TYPE" => "application/json", + "ACCEPT" => "application/json" + } + end + + describe "POST /api/v4/legacy_data_dumps" do + let(:valid_payload) do + { + patients: [ + { + id: SecureRandom.uuid, + full_name: "Test Patient", + age: 45, + gender: "male", + status: "active", + + medical_histories: [ + { + id: SecureRandom.uuid, + diabetes: "unknown", + prior_heart_attack: "yes" + } + ], + + blood_pressures: [ + { + id: SecureRandom.uuid, + systolic: 140, + diastolic: 90 + } + ], + + blood_sugars: [ + { + id: SecureRandom.uuid, + blood_sugar_type: "random", + blood_sugar_value: 180 + } + ], + + prescription_drugs: [ + { + id: SecureRandom.uuid, + name: "Amlodipine", + dosage: "5mg" + } + ], + + appointments: [ + { + id: SecureRandom.uuid, + scheduled_date: "2024-02-01", + status: "scheduled" + } + ], + + encounters: [ + { + id: SecureRandom.uuid, + notes: "Follow-up visit" + } + ] + } + ] + } + end + + it "creates a legacy data dump successfully" do + expect { + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: headers + }.to change(LegacyMobileDataDump, :count).by(1) + + expect(response).to have_http_status(:ok) + + json = JSON.parse(response.body) + expect(json["status"]).to eq("ok") + expect(json["id"]).to be_present + end + + it "stores the raw payload with nested legacy data per patient" do + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: headers + + dump = LegacyMobileDataDump.last + expect(dump.raw_payload["patients"]).to be_present + + patient = dump.raw_payload["patients"].first + + expect(patient["medical_histories"]).to be_present + expect(patient["blood_pressures"]).to be_present + expect(patient["blood_sugars"]).to be_present + expect(patient["prescription_drugs"]).to be_present + expect(patient["appointments"]).to be_present + expect(patient["encounters"]).to be_present + end + + it "records the user who made the dump" do + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: headers + + dump = LegacyMobileDataDump.last + expect(dump.user).to eq(request_user) + end + + it "records the mobile version from headers" do + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: headers + + dump = LegacyMobileDataDump.last + expect(dump.mobile_version).to eq("2024.1.0") + end + + it "records the dump date" do + freeze_time do + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: headers + + dump = LegacyMobileDataDump.last + expect(dump.dump_date).to be_within(1.second).of(Time.current) + end + end + + it "returns unauthorized with invalid token" do + invalid_headers = headers.merge("HTTP_AUTHORIZATION" => "Bearer invalid_token") + + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: invalid_headers + + expect(response).to have_http_status(:unauthorized) + end + + it "returns bad request without facility id" do + invalid_headers = headers.except("HTTP_X_FACILITY_ID") + + post "/api/v4/legacy_data_dumps", + params: valid_payload.to_json, + headers: invalid_headers + + expect(response).to have_http_status(:bad_request) + end + end +end