diff --git a/app/controllers/api/v1/beacons/sync_statuses_controller.rb b/app/controllers/api/v1/beacons/sync_statuses_controller.rb new file mode 100644 index 00000000..1a532f07 --- /dev/null +++ b/app/controllers/api/v1/beacons/sync_statuses_controller.rb @@ -0,0 +1,43 @@ +module Api + module V1 + module Beacons + class SyncStatusesController < Beacons::BaseController + def create + result = recorder.call(sync_status_params) + + if result.success + render json: { status: "accepted" }, status: :ok + else + render json: { errors: result.errors }, status: :unprocessable_entity + end + end + + private + + def recorder + ::Beacons::SyncStatusRecorder.new(Current.beacon) + end + + def sync_status_params + { + status: params[:status], + manifest_version: params[:manifest_version], + manifest_checksum: params[:manifest_checksum], + synced_at: params[:synced_at], + files_count: params[:files_count], + total_size_bytes: params[:total_size_bytes], + error_message: params[:error_message], + device_info: extract_device_info, + } + end + + def extract_device_info + value = params[:device_info] + return nil if value.blank? + + value.respond_to?(:to_unsafe_h) ? value.to_unsafe_h : value + end + end + end + end +end diff --git a/app/models/beacon.rb b/app/models/beacon.rb index 015ce242..6f0436ab 100644 --- a/app/models/beacon.rb +++ b/app/models/beacon.rb @@ -3,25 +3,36 @@ # Table name: beacons # Database name: primary # -# id :bigint not null, primary key -# api_key_digest :string not null -# api_key_prefix :string not null -# manifest_checksum :string -# manifest_data :jsonb -# manifest_version :integer default(0), not null -# name :string not null -# previous_manifest_data :jsonb -# revoked_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# language_id :bigint not null -# region_id :bigint not null +# id :bigint not null, primary key +# api_key_digest :string not null +# api_key_prefix :string not null +# device_info :jsonb +# last_seen_at :datetime +# last_sync_at :datetime +# last_sync_error :text +# manifest_checksum :string +# manifest_data :jsonb +# manifest_version :integer default(0), not null +# name :string not null +# previous_manifest_data :jsonb +# reported_files_count :integer +# reported_manifest_checksum :string +# reported_manifest_version :string +# reported_total_size_bytes :bigint +# revoked_at :datetime +# sync_status :string +# created_at :datetime not null +# updated_at :datetime not null +# language_id :bigint not null +# region_id :bigint not null # # Indexes # # index_beacons_on_api_key_digest (api_key_digest) UNIQUE # index_beacons_on_language_id (language_id) +# index_beacons_on_last_seen_at (last_seen_at) # index_beacons_on_region_id (region_id) +# index_beacons_on_sync_status (sync_status) # # Foreign Keys # @@ -29,6 +40,8 @@ # fk_rails_... (region_id => regions.id) # class Beacon < ApplicationRecord + SYNC_STATUSES = %w[synced syncing outdated error].freeze + belongs_to :language belongs_to :region @@ -41,6 +54,8 @@ class Beacon < ApplicationRecord delegate :name, to: :region, prefix: true delegate :name, to: :language, prefix: true + enum :sync_status, SYNC_STATUSES.index_with(&:itself) + validates :name, presence: true validates :api_key_digest, presence: true, uniqueness: true validates :api_key_prefix, presence: true diff --git a/app/services/beacons/sync_status_recorder.rb b/app/services/beacons/sync_status_recorder.rb new file mode 100644 index 00000000..32327d12 --- /dev/null +++ b/app/services/beacons/sync_status_recorder.rb @@ -0,0 +1,57 @@ +module Beacons + class SyncStatusRecorder + Result = Data.define(:success, :errors) + + def initialize(beacon, clock: Time) + @beacon = beacon + @clock = clock + end + + def call(payload) + attrs = normalize(payload) + return Result.new(success: false, errors: [ "status is invalid" ]) unless valid_status?(attrs[:sync_status]) + + beacon.update!(attrs) + Result.new(success: true, errors: []) + end + + private + + attr_reader :beacon, :clock + + def normalize(payload) + now = clock.current + status = payload[:status].to_s + + { + sync_status: status, + last_seen_at: now, + last_sync_at: last_sync_at_for(status, payload[:synced_at]), + reported_manifest_version: payload[:manifest_version], + reported_manifest_checksum: payload[:manifest_checksum], + reported_files_count: payload[:files_count], + reported_total_size_bytes: payload[:total_size_bytes], + last_sync_error: status == "error" ? payload[:error_message] : nil, + device_info: payload[:device_info], + } + end + + def last_sync_at_for(status, synced_at) + return beacon.last_sync_at unless status == "synced" + + parse_time(synced_at) || clock.current + end + + def parse_time(value) + return nil if value.blank? + + Time.iso8601(value.to_s) + rescue ArgumentError + nil + end + + def valid_status?(status) + Beacon::SYNC_STATUSES.include?(status) + end + end +end diff --git a/config/routes.rb b/config/routes.rb index d044bbbf..70545080 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -53,6 +53,7 @@ namespace :beacons do resources :files, only: :show resource :status, only: :show + resource :sync_status, only: :create resource :manifest, only: :show end end diff --git a/db/migrate/20260203100008_add_sync_status_to_beacons.rb b/db/migrate/20260203100008_add_sync_status_to_beacons.rb new file mode 100644 index 00000000..687f4338 --- /dev/null +++ b/db/migrate/20260203100008_add_sync_status_to_beacons.rb @@ -0,0 +1,16 @@ +class AddSyncStatusToBeacons < ActiveRecord::Migration[8.0] + def change + add_column :beacons, :sync_status, :string + add_column :beacons, :last_seen_at, :datetime + add_column :beacons, :last_sync_at, :datetime + add_column :beacons, :reported_manifest_version, :string + add_column :beacons, :reported_manifest_checksum, :string + add_column :beacons, :reported_files_count, :integer + add_column :beacons, :reported_total_size_bytes, :bigint + add_column :beacons, :last_sync_error, :text + add_column :beacons, :device_info, :jsonb + + add_index :beacons, :sync_status + add_index :beacons, :last_seen_at + end +end diff --git a/db/schema.rb b/db/schema.rb index c89e0c89..396bd4ce 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_02_03_100007) do +ActiveRecord::Schema[8.1].define(version: 2026_02_03_100008) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -66,18 +66,29 @@ t.string "api_key_digest", null: false t.string "api_key_prefix", null: false t.datetime "created_at", null: false + t.jsonb "device_info" t.bigint "language_id", null: false + t.datetime "last_seen_at" + t.datetime "last_sync_at" + t.text "last_sync_error" t.string "manifest_checksum" t.jsonb "manifest_data" t.integer "manifest_version", default: 0, null: false t.string "name", null: false t.jsonb "previous_manifest_data" t.bigint "region_id", null: false + t.integer "reported_files_count" + t.string "reported_manifest_checksum" + t.string "reported_manifest_version" + t.bigint "reported_total_size_bytes" t.datetime "revoked_at" + t.string "sync_status" t.datetime "updated_at", null: false t.index ["api_key_digest"], name: "index_beacons_on_api_key_digest", unique: true t.index ["language_id"], name: "index_beacons_on_language_id" + t.index ["last_seen_at"], name: "index_beacons_on_last_seen_at" t.index ["region_id"], name: "index_beacons_on_region_id" + t.index ["sync_status"], name: "index_beacons_on_sync_status" end create_table "branches", force: :cascade do |t| diff --git a/spec/factories/beacons.rb b/spec/factories/beacons.rb index 4e699f17..a07d88e9 100644 --- a/spec/factories/beacons.rb +++ b/spec/factories/beacons.rb @@ -3,25 +3,36 @@ # Table name: beacons # Database name: primary # -# id :bigint not null, primary key -# api_key_digest :string not null -# api_key_prefix :string not null -# manifest_checksum :string -# manifest_data :jsonb -# manifest_version :integer default(0), not null -# name :string not null -# previous_manifest_data :jsonb -# revoked_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# language_id :bigint not null -# region_id :bigint not null +# id :bigint not null, primary key +# api_key_digest :string not null +# api_key_prefix :string not null +# device_info :jsonb +# last_seen_at :datetime +# last_sync_at :datetime +# last_sync_error :text +# manifest_checksum :string +# manifest_data :jsonb +# manifest_version :integer default(0), not null +# name :string not null +# previous_manifest_data :jsonb +# reported_files_count :integer +# reported_manifest_checksum :string +# reported_manifest_version :string +# reported_total_size_bytes :bigint +# revoked_at :datetime +# sync_status :string +# created_at :datetime not null +# updated_at :datetime not null +# language_id :bigint not null +# region_id :bigint not null # # Indexes # # index_beacons_on_api_key_digest (api_key_digest) UNIQUE # index_beacons_on_language_id (language_id) +# index_beacons_on_last_seen_at (last_seen_at) # index_beacons_on_region_id (region_id) +# index_beacons_on_sync_status (sync_status) # # Foreign Keys # diff --git a/spec/models/beacon_spec.rb b/spec/models/beacon_spec.rb index ebebd382..9be7faf8 100644 --- a/spec/models/beacon_spec.rb +++ b/spec/models/beacon_spec.rb @@ -3,25 +3,36 @@ # Table name: beacons # Database name: primary # -# id :bigint not null, primary key -# api_key_digest :string not null -# api_key_prefix :string not null -# manifest_checksum :string -# manifest_data :jsonb -# manifest_version :integer default(0), not null -# name :string not null -# previous_manifest_data :jsonb -# revoked_at :datetime -# created_at :datetime not null -# updated_at :datetime not null -# language_id :bigint not null -# region_id :bigint not null +# id :bigint not null, primary key +# api_key_digest :string not null +# api_key_prefix :string not null +# device_info :jsonb +# last_seen_at :datetime +# last_sync_at :datetime +# last_sync_error :text +# manifest_checksum :string +# manifest_data :jsonb +# manifest_version :integer default(0), not null +# name :string not null +# previous_manifest_data :jsonb +# reported_files_count :integer +# reported_manifest_checksum :string +# reported_manifest_version :string +# reported_total_size_bytes :bigint +# revoked_at :datetime +# sync_status :string +# created_at :datetime not null +# updated_at :datetime not null +# language_id :bigint not null +# region_id :bigint not null # # Indexes # # index_beacons_on_api_key_digest (api_key_digest) UNIQUE # index_beacons_on_language_id (language_id) +# index_beacons_on_last_seen_at (last_seen_at) # index_beacons_on_region_id (region_id) +# index_beacons_on_sync_status (sync_status) # # Foreign Keys # diff --git a/spec/requests/api/v1/beacons/sync_statuses_spec.rb b/spec/requests/api/v1/beacons/sync_statuses_spec.rb new file mode 100644 index 00000000..fbf4fd56 --- /dev/null +++ b/spec/requests/api/v1/beacons/sync_statuses_spec.rb @@ -0,0 +1,139 @@ +require "rails_helper" + +RSpec.describe "Beacons Sync Status API", type: :request do + let(:beacon_with_key) { create_beacon_with_key } + let(:beacon) { beacon_with_key.first } + let(:raw_key) { beacon_with_key.last } + + let(:valid_payload) do + { + status: "synced", + manifest_version: "v43", + manifest_checksum: "sha256:xyz", + synced_at: "2026-04-21T11:59:00Z", + files_count: 47, + total_size_bytes: 156_000_000, + device_info: { + hostname: "clinic-pc-001", + os_version: "Ubuntu 22.04", + app_version: "1.0.0", + }, + } + end + + describe "POST /api/v1/beacons/sync_status" do + context "with a valid payload" do + it "returns 200 OK with an acknowledgment" do + post "/api/v1/beacons/sync_status", + params: valid_payload, + headers: beacon_auth_headers(raw_key), + as: :json + + expect(response).to have_http_status(:ok) + expect(response.parsed_body["status"]).to eq("accepted") + end + + it "stores the reported fields on the beacon" do + post "/api/v1/beacons/sync_status", + params: valid_payload, + headers: beacon_auth_headers(raw_key), + as: :json + + beacon.reload + expect(beacon.sync_status).to eq("synced") + expect(beacon.reported_manifest_version).to eq("v43") + expect(beacon.reported_manifest_checksum).to eq("sha256:xyz") + expect(beacon.reported_files_count).to eq(47) + expect(beacon.reported_total_size_bytes).to eq(156_000_000) + expect(beacon.device_info).to include("hostname" => "clinic-pc-001") + end + + it "updates last_seen_at and last_sync_at" do + post "/api/v1/beacons/sync_status", + params: valid_payload, + headers: beacon_auth_headers(raw_key), + as: :json + + beacon.reload + expect(beacon.last_seen_at).to be_within(5.seconds).of(Time.current) + expect(beacon.last_sync_at).to eq(Time.iso8601("2026-04-21T11:59:00Z")) + end + end + + context "with non-synced statuses" do + it "accepts syncing status" do + post "/api/v1/beacons/sync_status", + params: valid_payload.merge(status: "syncing"), + headers: beacon_auth_headers(raw_key), + as: :json + + expect(response).to have_http_status(:ok) + expect(beacon.reload.sync_status).to eq("syncing") + end + + it "accepts outdated status" do + post "/api/v1/beacons/sync_status", + params: valid_payload.merge(status: "outdated"), + headers: beacon_auth_headers(raw_key), + as: :json + + expect(response).to have_http_status(:ok) + expect(beacon.reload.sync_status).to eq("outdated") + end + + it "accepts error status with error_message" do + post "/api/v1/beacons/sync_status", + params: valid_payload.merge(status: "error", error_message: "Disk full"), + headers: beacon_auth_headers(raw_key), + as: :json + + expect(response).to have_http_status(:ok) + beacon.reload + expect(beacon.sync_status).to eq("error") + expect(beacon.last_sync_error).to eq("Disk full") + end + end + + context "with an invalid status value" do + it "returns 422 Unprocessable Entity" do + post "/api/v1/beacons/sync_status", + params: valid_payload.merge(status: "banana"), + headers: beacon_auth_headers(raw_key), + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + expect(response.parsed_body["errors"]).to include("status is invalid") + end + + it "does not update the beacon" do + post "/api/v1/beacons/sync_status", + params: valid_payload.merge(status: "banana"), + headers: beacon_auth_headers(raw_key), + as: :json + + expect(beacon.reload.sync_status).to be_nil + end + end + + context "without authentication" do + it "returns 401 Unauthorized" do + post "/api/v1/beacons/sync_status", params: valid_payload, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context "with a revoked beacon" do + before { beacon.revoke! } + + it "returns 401 Unauthorized" do + post "/api/v1/beacons/sync_status", + params: valid_payload, + headers: beacon_auth_headers(raw_key), + as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/services/beacons/sync_status_recorder_spec.rb b/spec/services/beacons/sync_status_recorder_spec.rb new file mode 100644 index 00000000..f4a495f5 --- /dev/null +++ b/spec/services/beacons/sync_status_recorder_spec.rb @@ -0,0 +1,128 @@ +require "rails_helper" + +RSpec.describe Beacons::SyncStatusRecorder do + subject(:recorder) { described_class.new(beacon, clock: clock) } + + let(:beacon) { create(:beacon) } + let(:now) { Time.zone.local(2026, 4, 21, 12, 0, 0) } + let(:clock) { class_double(Time, current: now) } + + describe "#call" do + let(:base_payload) do + { + status: "synced", + manifest_version: "v43", + manifest_checksum: "sha256:xyz", + synced_at: "2026-04-21T11:59:00Z", + files_count: 47, + total_size_bytes: 156_000_000, + device_info: { "hostname" => "clinic-pc-001", "os_version" => "Ubuntu 22.04", "app_version" => "1.0.0" }, + } + end + + context "with a valid synced payload" do + it "returns a successful result" do + result = recorder.call(base_payload) + + expect(result.success).to be(true) + expect(result.errors).to be_empty + end + + it "persists the reported sync fields on the beacon" do + recorder.call(base_payload) + + beacon.reload + expect(beacon.sync_status).to eq("synced") + expect(beacon.reported_manifest_version).to eq("v43") + expect(beacon.reported_manifest_checksum).to eq("sha256:xyz") + expect(beacon.reported_files_count).to eq(47) + expect(beacon.reported_total_size_bytes).to eq(156_000_000) + expect(beacon.device_info).to eq(base_payload[:device_info]) + end + + it "sets last_seen_at to the injected clock time" do + recorder.call(base_payload) + + expect(beacon.reload.last_seen_at).to eq(now) + end + + it "sets last_sync_at to the parsed synced_at timestamp" do + recorder.call(base_payload) + + expect(beacon.reload.last_sync_at).to eq(Time.iso8601("2026-04-21T11:59:00Z")) + end + + it "falls back to clock time when synced_at is missing for synced status" do + recorder.call(base_payload.except(:synced_at)) + + expect(beacon.reload.last_sync_at).to eq(now) + end + + it "falls back to clock time when synced_at is malformed" do + recorder.call(base_payload.merge(synced_at: "not-a-date")) + + expect(beacon.reload.last_sync_at).to eq(now) + end + end + + context "when status is not synced" do + it "preserves last_sync_at for syncing status" do + beacon.update!(last_sync_at: Time.zone.local(2026, 4, 20, 9, 0, 0)) + + recorder.call(base_payload.merge(status: "syncing")) + + beacon.reload + expect(beacon.sync_status).to eq("syncing") + expect(beacon.last_sync_at).to eq(Time.zone.local(2026, 4, 20, 9, 0, 0)) + expect(beacon.last_seen_at).to eq(now) + end + + it "updates last_seen_at for outdated status without touching last_sync_at" do + beacon.update!(last_sync_at: Time.zone.local(2026, 4, 19, 8, 0, 0)) + + recorder.call(base_payload.merge(status: "outdated")) + + beacon.reload + expect(beacon.sync_status).to eq("outdated") + expect(beacon.last_sync_at).to eq(Time.zone.local(2026, 4, 19, 8, 0, 0)) + expect(beacon.last_seen_at).to eq(now) + end + + it "stores the error_message when status is error" do + recorder.call(base_payload.merge(status: "error", error_message: "Network timeout")) + + beacon.reload + expect(beacon.sync_status).to eq("error") + expect(beacon.last_sync_error).to eq("Network timeout") + end + + it "clears last_sync_error when status is not error" do + beacon.update!(last_sync_error: "Previous failure") + + recorder.call(base_payload.merge(status: "syncing")) + + expect(beacon.reload.last_sync_error).to be_nil + end + end + + context "with an invalid status" do + it "returns failure and does not touch the beacon" do + result = recorder.call(base_payload.merge(status: "banana")) + + expect(result.success).to be(false) + expect(result.errors).to include("status is invalid") + + beacon.reload + expect(beacon.sync_status).to be_nil + expect(beacon.last_seen_at).to be_nil + end + + it "rejects a missing status" do + result = recorder.call(base_payload.except(:status)) + + expect(result.success).to be(false) + expect(result.errors).to include("status is invalid") + end + end + end +end